From db089af0bf555aee9ea94090baafbce028439164 Mon Sep 17 00:00:00 2001 From: starmen1265 Date: Fri, 27 Feb 2026 18:10:42 +0800 Subject: [PATCH] Remove kb- skills from skills directory --- skills/kb-bilibili-content/SKILL.md | 91 - .../bilibili_content.cpython-312.pyc | Bin 22390 -> 0 bytes .../kb-bilibili-content/bilibili_content.py | 419 ---- .../kb-bilibili-content/bilibili_extractor.py | 155 -- .../kb-bilibili-content/video_downloader.py | 175 -- skills/kb-bilibili-metadata/SKILL.md | 46 - skills/kb-bilibili-metadata/__init__.py | 8 - .../bilibili_extractor.cpython-312.pyc | Bin 32341 -> 0 bytes .../bilibili_extractor.py | 666 ------- .../kb-bilibili-metadata/bilibili_metadata.py | 228 --- skills/kb-deep-analyzer/SKILL.md | 94 - .../__pycache__/deep_analyzer.cpython-312.pyc | Bin 75330 -> 0 bytes skills/kb-deep-analyzer/ai_client.py | 514 ----- skills/kb-deep-analyzer/deep_analyzer.py | 511 ----- skills/kb-deep-analyzer/deep_analyzer.py.bak | 1727 ----------------- skills/kb-funasr/.env.example | 2 - skills/kb-funasr/README.md | 212 -- skills/kb-funasr/SKILL.md | 113 -- .../__pycache__/funasr.cpython-312.pyc | Bin 12468 -> 0 bytes skills/kb-funasr/funasr_tool.py | 313 --- skills/kb-link-parser/SKILL.md | 43 - .../link_parser_shortlink.cpython-312.pyc | Bin 10849 -> 0 bytes skills/kb-link-parser/link-parser.py | 637 ------ .../kb-link-parser/link_parser_shortlink.py | 296 --- skills/kb-ocr-processor/SKILL.md | 66 - skills/kb-ocr-processor/ocr_processor.py | 360 ---- skills/kb-ocr-processor/requirements.txt | 3 - skills/kb-whisper-diarization/.env.example | 9 - skills/kb-whisper-diarization/SKILL.md | 127 -- .../kb-whisper-diarization/requirements.txt | 8 - .../whisper_diarization.py | 339 ---- skills/kb-whisper/.env.example | 5 - skills/kb-whisper/README.md | 211 -- skills/kb-whisper/SKILL.md | 124 -- .../__pycache__/whisper.cpython-312.pyc | Bin 13164 -> 0 bytes skills/kb-whisper/examples.md | 273 --- skills/kb-whisper/whisper.py | 403 ---- skills/kb-workflow/.env | 14 - skills/kb-workflow/.env.example | 14 - skills/kb-workflow/README.md | 247 --- skills/kb-workflow/SKILL.md | 360 ---- skills/kb-workflow/SkillBug.md | 101 - skills/kb-workflow/install-env.sh | 321 --- skills/kb-workflow/kb_workflow.py | 337 ---- skills/kb-zhihu-metadata/SKILL.md | 44 - .../kb-zhihu-metadata/markdown_converter.py | 17 - skills/kb-zhihu-metadata/zhihu_extractor.py | 335 ---- skills/kb-zhihu-metadata/zhihu_metadata.py | 153 -- 48 files changed, 10121 deletions(-) delete mode 100644 skills/kb-bilibili-content/SKILL.md delete mode 100644 skills/kb-bilibili-content/__pycache__/bilibili_content.cpython-312.pyc delete mode 100644 skills/kb-bilibili-content/bilibili_content.py delete mode 100644 skills/kb-bilibili-content/bilibili_extractor.py delete mode 100644 skills/kb-bilibili-content/video_downloader.py delete mode 100644 skills/kb-bilibili-metadata/SKILL.md delete mode 100644 skills/kb-bilibili-metadata/__init__.py delete mode 100644 skills/kb-bilibili-metadata/__pycache__/bilibili_extractor.cpython-312.pyc delete mode 100644 skills/kb-bilibili-metadata/bilibili_extractor.py delete mode 100755 skills/kb-bilibili-metadata/bilibili_metadata.py delete mode 100644 skills/kb-deep-analyzer/SKILL.md delete mode 100644 skills/kb-deep-analyzer/__pycache__/deep_analyzer.cpython-312.pyc delete mode 100644 skills/kb-deep-analyzer/ai_client.py delete mode 100644 skills/kb-deep-analyzer/deep_analyzer.py delete mode 100644 skills/kb-deep-analyzer/deep_analyzer.py.bak delete mode 100644 skills/kb-funasr/.env.example delete mode 100644 skills/kb-funasr/README.md delete mode 100644 skills/kb-funasr/SKILL.md delete mode 100644 skills/kb-funasr/__pycache__/funasr.cpython-312.pyc delete mode 100755 skills/kb-funasr/funasr_tool.py delete mode 100644 skills/kb-link-parser/SKILL.md delete mode 100644 skills/kb-link-parser/__pycache__/link_parser_shortlink.cpython-312.pyc delete mode 100644 skills/kb-link-parser/link-parser.py delete mode 100644 skills/kb-link-parser/link_parser_shortlink.py delete mode 100644 skills/kb-ocr-processor/SKILL.md delete mode 100644 skills/kb-ocr-processor/ocr_processor.py delete mode 100644 skills/kb-ocr-processor/requirements.txt delete mode 100644 skills/kb-whisper-diarization/.env.example delete mode 100644 skills/kb-whisper-diarization/SKILL.md delete mode 100644 skills/kb-whisper-diarization/requirements.txt delete mode 100644 skills/kb-whisper-diarization/whisper_diarization.py delete mode 100644 skills/kb-whisper/.env.example delete mode 100644 skills/kb-whisper/README.md delete mode 100644 skills/kb-whisper/SKILL.md delete mode 100644 skills/kb-whisper/__pycache__/whisper.cpython-312.pyc delete mode 100644 skills/kb-whisper/examples.md delete mode 100755 skills/kb-whisper/whisper.py delete mode 100644 skills/kb-workflow/.env delete mode 100644 skills/kb-workflow/.env.example delete mode 100644 skills/kb-workflow/README.md delete mode 100644 skills/kb-workflow/SKILL.md delete mode 100644 skills/kb-workflow/SkillBug.md delete mode 100755 skills/kb-workflow/install-env.sh delete mode 100644 skills/kb-workflow/kb_workflow.py delete mode 100644 skills/kb-zhihu-metadata/SKILL.md delete mode 100644 skills/kb-zhihu-metadata/markdown_converter.py delete mode 100644 skills/kb-zhihu-metadata/zhihu_extractor.py delete mode 100644 skills/kb-zhihu-metadata/zhihu_metadata.py diff --git a/skills/kb-bilibili-content/SKILL.md b/skills/kb-bilibili-content/SKILL.md deleted file mode 100644 index 4701426..0000000 --- a/skills/kb-bilibili-content/SKILL.md +++ /dev/null @@ -1,91 +0,0 @@ -# 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 -f -o ` -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)** diff --git a/skills/kb-bilibili-content/__pycache__/bilibili_content.cpython-312.pyc b/skills/kb-bilibili-content/__pycache__/bilibili_content.cpython-312.pyc deleted file mode 100644 index d8b5ed39c42ec591ff4460720b6f2cd3e7cd9dc5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22390 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 M^)m4VnFQ$n2XJFdCjbBd diff --git a/skills/kb-bilibili-content/bilibili_content.py b/skills/kb-bilibili-content/bilibili_content.py deleted file mode 100644 index 08bfbef..0000000 --- a/skills/kb-bilibili-content/bilibili_content.py +++ /dev/null @@ -1,419 +0,0 @@ -#!/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) diff --git a/skills/kb-bilibili-content/bilibili_extractor.py b/skills/kb-bilibili-content/bilibili_extractor.py deleted file mode 100644 index 971d239..0000000 --- a/skills/kb-bilibili-content/bilibili_extractor.py +++ /dev/null @@ -1,155 +0,0 @@ -#!/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() diff --git a/skills/kb-bilibili-content/video_downloader.py b/skills/kb-bilibili-content/video_downloader.py deleted file mode 100644 index f5d25c4..0000000 --- a/skills/kb-bilibili-content/video_downloader.py +++ /dev/null @@ -1,175 +0,0 @@ -#!/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❌ 下载失败") diff --git a/skills/kb-bilibili-metadata/SKILL.md b/skills/kb-bilibili-metadata/SKILL.md deleted file mode 100644 index d90d862..0000000 --- a/skills/kb-bilibili-metadata/SKILL.md +++ /dev/null @@ -1,46 +0,0 @@ -# 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/ -``` diff --git a/skills/kb-bilibili-metadata/__init__.py b/skills/kb-bilibili-metadata/__init__.py deleted file mode 100644 index 0614e1f..0000000 --- a/skills/kb-bilibili-metadata/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -""" -bilibili-metadata 技能包 - -B站元数据提取工具 -""" - -__version__ = "1.0.0" -__author__ = "OpenClaw" diff --git a/skills/kb-bilibili-metadata/__pycache__/bilibili_extractor.cpython-312.pyc b/skills/kb-bilibili-metadata/__pycache__/bilibili_extractor.cpython-312.pyc deleted file mode 100644 index 49393a3f0ec777346a6b364f5df345c8a0e30e1f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32341 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 Z(da6r%?ZL^#M9^|OPh7VUu4nX{(pdDX@md( diff --git a/skills/kb-bilibili-metadata/bilibili_extractor.py b/skills/kb-bilibili-metadata/bilibili_extractor.py deleted file mode 100644 index 86fee7b..0000000 --- a/skills/kb-bilibili-metadata/bilibili_extractor.py +++ /dev/null @@ -1,666 +0,0 @@ -#!/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)) diff --git a/skills/kb-bilibili-metadata/bilibili_metadata.py b/skills/kb-bilibili-metadata/bilibili_metadata.py deleted file mode 100755 index f4635fd..0000000 --- a/skills/kb-bilibili-metadata/bilibili_metadata.py +++ /dev/null @@ -1,228 +0,0 @@ -#!/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() diff --git a/skills/kb-deep-analyzer/SKILL.md b/skills/kb-deep-analyzer/SKILL.md deleted file mode 100644 index 4cbf26c..0000000 --- a/skills/kb-deep-analyzer/SKILL.md +++ /dev/null @@ -1,94 +0,0 @@ -# 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" - } - } -} -``` - -会自动转换为扁平格式进行分析。 diff --git a/skills/kb-deep-analyzer/__pycache__/deep_analyzer.cpython-312.pyc b/skills/kb-deep-analyzer/__pycache__/deep_analyzer.cpython-312.pyc deleted file mode 100644 index 4837a2ea5a323adef706afe69b8375233d9b209b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 75330 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 EzetYvh5!Hn diff --git a/skills/kb-deep-analyzer/ai_client.py b/skills/kb-deep-analyzer/ai_client.py deleted file mode 100644 index cee3c73..0000000 --- a/skills/kb-deep-analyzer/ai_client.py +++ /dev/null @@ -1,514 +0,0 @@ -"""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) diff --git a/skills/kb-deep-analyzer/deep_analyzer.py b/skills/kb-deep-analyzer/deep_analyzer.py deleted file mode 100644 index 205f378..0000000 --- a/skills/kb-deep-analyzer/deep_analyzer.py +++ /dev/null @@ -1,511 +0,0 @@ -#!/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) diff --git a/skills/kb-deep-analyzer/deep_analyzer.py.bak b/skills/kb-deep-analyzer/deep_analyzer.py.bak deleted file mode 100644 index 59c62c1..0000000 --- a/skills/kb-deep-analyzer/deep_analyzer.py.bak +++ /dev/null @@ -1,1727 +0,0 @@ -#!/usr/bin/env python3 -"""Deep Analyzer - AI 深度内容分析工具 - -支持根据内容时长选择不同分析策略: -- 短视频 (<10分钟): 轻量分析 -- 长视频 (>=10分钟): 详细时间轴分析 - -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, Optional, Tuple -from dataclasses import dataclass, field, asdict - - -# ============== 数据模型 ============== - -@dataclass -class TimelineEntry: - """时间轴条目""" - index: int = 0 - start_time: str = "" - end_time: str = "" - title: str = "" - summary: str = "" - - -@dataclass -class AnalysisResult: - """分析结果""" - summary: str = "" - main_topics: List[str] = field(default_factory=list) - key_insights: List[str] = field(default_factory=list) - action_items: List[str] = field(default_factory=list) - recommended_tags: List[str] = field(default_factory=list) - full_analysis: str = "" - timeline: List[Dict[str, Any]] = field(default_factory=list) - - -# ============== AI 客户端 ============== - -# ============== 流式写入器 ============== - -import threading - - -class StreamingWriter: - """ - 流式写入器 - 支持多线程并发写入到文档不同位置 - - 使用方法: - 1. 初始化: writer = StreamingWriter(output_path, num_chunks) - 2. 预先创建带占位符的文档结构 - 3. 每批完成后调用 writer.write_batch(batch_results) - 4. 完成后调用 writer.mark_complete() 清理占位符 - """ - - def __init__(self, output_path: str, num_chunks: int = 0, batch_size: int = 3): - self.output_path = Path(output_path) - self.num_chunks = num_chunks - self.batch_size = batch_size - self.lock = threading.Lock() - self.written = {} # 记录已写入的位置 - self.batch_num = 0 - - def initialize_document(self, metadata: Dict, num_chunks: int) -> str: - """ - 初始化文档结构,返回带占位符的内容 - - 文档结构: - --- - title: "xxx" - ... - --- - - # 标题 - - ## 摘要 - - ## 主要主题 - - ## 关键见解 - - ## 建议行动 - - ## 时间轴 - ### 第 N 部分 (00:00 - 00:00) - [ANALYZING...] - """ - self.num_chunks = num_chunks - - # 生成时间轴占位符 - timeline_placeholders = "" - for i in range(num_chunks): - timeline_placeholders += f"""### 第 {i + 1} 部分 (待分析) -[ANALYZING_BATCH_{i // self.batch_size + 1}] - -""" - - content = f"""--- -title: "{metadata.get('title', '未命名')}" -date: {datetime.now().strftime('%Y-%m-%d')} -duration: {format_duration(metadata.get('duration', 0))} -author: {metadata.get('author', '未知')} -analyze_time: {datetime.now().strftime('%Y-%m-%d %H:%M')} -bvid: {metadata.get('bvid', '')} -platform: {metadata.get('platform', 'bilibili')} ---- - -# {metadata.get('title', '未命名')} - -## 摘要 -[WAITING_FOR_ANALYSIS...] - -## 主要主题 -[WAITING_FOR_ANALYSIS...] - -## 关键见解 -[WAITING_FOR_ANALYSIS...] - -## 建议行动 -[WAITING_FOR_ANALYSIS...] - -## 时间轴 -{timeline_placeholders} - ---- -*分析时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}* -""" - - # 写入初始化内容 - self.output_path.parent.mkdir(parents=True, exist_ok=True) - with open(self.output_path, 'w', encoding='utf-8') as f: - f.write(content) - - print(f"[StreamingWriter] 初始化文档: {self.output_path}") - return content - - def write_batch(self, batch_results: List[Dict], num_batches: int) -> None: - """ - 写入一批结果 - - Args: - batch_results: 当前批的结果列表 - num_batches: 总批次数 - """ - self.batch_num += 1 - - with self.lock: - # 读取当前内容 - if not self.output_path.exists(): - print(f"[StreamingWriter] 警告: 文件不存在 {self.output_path}") - return - - content = self.output_path.read_text(encoding='utf-8') - - for result in batch_results: - idx = result['index'] - start_time = result['start_time'] - end_time = result['end_time'] - summary = result['summary'] - - # 查找并替换占位符 - old_placeholder = f"### 第 {idx + 1} 部分 ({start_time} - {end_time})\n[ANALYZING_BATCH_{self.batch_num}]" - new_content = f"""### 第 {idx + 1} 部分 ({start_time} - {end_time}) -{summary}""" - - content = content.replace(old_placeholder, new_content, 1) - self.written[idx] = True - - print(f"[StreamingWriter] 写入段 {idx + 1}/{self.num_chunks}") - - # 如果这是最后一批,更新其他状态 - if self.batch_num >= num_batches: - # 更新摘要等 - pass - - # 写回文件 - self.output_path.write_text(content, encoding='utf-8') - print(f"[StreamingWriter] 批次 {self.batch_num}/{num_batches} 完成") - - def update_summary(self, summary_text: str, topics: List[str], insights: List[str]) -> None: - """更新摘要等全局内容""" - with self.lock: - if not self.output_path.exists(): - return - - content = self.output_path.read_text(encoding='utf-8') - - # 更新摘要 - if "[WAITING_FOR_ANALYSIS...]" in content: - content = content.replace("## 摘要\n[WAITING_FOR_ANALYSIS...]", f"## 摘要\n{summary_text}") - - # 更新主题 - topics_text = "\n".join(f"- {t}" for t in topics) - if "## 主要主题\n[WAITING_FOR_ANALYSIS...]" in content: - content = content.replace("## 主要主题\n[WAITING_FOR_ANALYSIS...]", f"## 主要主题\n{topics_text}") - - # 更新见解 - insights_text = "\n".join(f"- {i}" for i in insights) - if "## 关键见解\n[WAITING_FOR_ANALYSIS...]" in content: - content = content.replace("## 关键见解\n[WAITING_FOR_ANALYSIS...]", f"## 关键见解\n{insights_text}") - - self.output_path.write_text(content, encoding='utf-8') - print(f"[StreamingWriter] 更新摘要和主题") - - def cleanup_placeholder(self, placeholder: str) -> None: - """清理单个占位符""" - with self.lock: - if not self.output_path.exists(): - return - - content = self.output_path.read_text(encoding='utf-8') - content = content.replace(placeholder, "") - self.output_path.write_text(content, encoding='utf-8') - - -class SimpleAIClient: - """简化的 AI 客户端 - 支持多提供商 fallback""" - - # 时长阈值(秒) - SHORT_VIDEO_THRESHOLD = 600 # 10分钟 - LONG_VIDEO_THRESHOLD = 1800 # 30分钟 - - 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 # 最大重试次数 - self.max_concurrent = 3 # 最大并发数 - - def generate(self, prompt: str, max_tokens: int = 1000, task_info: str = None) -> str: - """生成文本,支持重试""" - # 每次调用时重新读取环境变量,解决 exec 传递问题 - 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 未设置,请配置环境变量") - - task_desc = f"[{task_info}]" if task_info else "" - - last_error = None - for attempt in range(self.max_retries): - try: - import time - start_time = time.time() - print(f"[AI Client] {task_desc} 开始调用 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] {task_desc} AI 调用完成,耗时: {elapsed:.2f}秒") - return result - except Exception as e: - elapsed = time.time() - start_time if 'start_time' in dir() else 0 - last_error = e - if attempt < self.max_retries - 1: - print(f"[AI Client] {task_desc} 第 {attempt + 1} 次调用失败 ({elapsed:.2f}秒),{self.max_retries - attempt - 1} 次重试剩余: {e}") - continue - else: - print(f"[AI Client] {task_desc} 调用失败,已重试 {self.max_retries} 次: {e}") - raise - - def generate_json(self, prompt: str) -> Dict[str, Any]: - """生成 JSON 格式响应""" - response = self.generate(prompt, max_tokens=4000) - try: - return json.loads(response) - except json.JSONDecodeError: - # 尝试从文本中提取 JSON - return self._parse_json_from_text(response) - - def _call_openai(self, prompt: str, max_tokens: int, api_key: str = None, model: str = None, base_url: str = None) -> str: - """调用 OpenAI API""" - import requests - url = f"{self.base_url}/chat/completions" if self.base_url else "https://api.openai.com/v1/chat/completions" - - headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json" - } - - payload = { - "model": self.model, - "max_tokens": max_tokens, - "messages": [{"role": "user", "content": prompt}] - } - - response = requests.post(url, headers=headers, json=payload, timeout=600) - response.raise_for_status() - response.raise_for_status() - return response.json()['choices'][0]['message']['content'] - - def _call_zhipu(self, prompt: str, max_tokens: int, api_key: str = None, model: str = None, base_url: str = None) -> str: - """调用智谱 AI API - 返回 reasoning_content""" - import requests - base_url = self.base_url or "https://open.bigmodel.cn/api/coding/paas/v4" - url = f"{base_url}/chat/completions" - - headers = {"Authorization": f"Bearer {self.api_key}"} - payload = { - "model": self.model, - "messages": [{"role": "user", "content": prompt}], - "max_tokens": max_tokens - } - - response = requests.post(url, headers=headers, json=payload, timeout=600) - response.raise_for_status() - response.raise_for_status() - data = response.json() - - message = data.get('choices', [{}])[0].get('message', {}) - content = message.get('content', '') - reasoning = message.get('reasoning_content', '') - - # 如果 content 为空但有 reasoning_content,从 reasoning 中提取关键句 - if not content and reasoning: - # 从 reasoning_content 中提取最后几句话作为答案 - 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 _parse_json_from_text(self, text: str) -> Dict[str, Any]: - """从文本中解析 JSON,改进版本,支持更多格式""" - # 尝试直接解析 - try: - return json.loads(text) - except json.JSONDecodeError: - pass - - # 尝试提取 JSON 块 - json_match = re.search(r'```(?:json)?\s*([\s\S]*?)\s*```', text) - if json_match: - try: - return json.loads(json_match.group(1)) - except: - pass - - # 尝试匹配 { ... } - json_match = re.search(r'(\{[\s\S]*\})', text) - if json_match: - try: - return json.loads(json_match.group(1)) - except: - pass - - return {'raw_response': text} - - def _call_minimax(self, prompt: str, max_tokens: int, api_key: str = None, model: str = None, base_url: str = None) -> str: - """调用 MiniMax API""" - import requests - url = self.base_url or "https://api.minimaxi.com/anthropic/v1/messages" - - headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json" - } - - payload = { - "model": self.model, - "max_tokens": max_tokens, - "messages": [{"role": "user", "content": prompt}] - } - - response = requests.post(url, headers=headers, json=payload, timeout=600) - response.raise_for_status() - 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: - """深度内容分析器""" - - SHORT_VIDEO_THRESHOLD = 600 # 10分钟 - LONG_VIDEO_THRESHOLD = 1800 # 30分钟 - - 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], - content_type: str = "video", - on_batch_complete: callable = None, - batch_size: int = 3 - ) -> AnalysisResult: - """ - 分析内容 - - Args: - metadata: 元数据字典 - content: 内容数据字典(包含 subtitle, description 等) - content_type: 内容类型 - on_batch_complete: 每批完成时的回调函数 (batch_results) => None - batch_size: 每批处理数(默认3) - - Returns: - AnalysisResult 分析结果 - """ - # 先标准化元数据 - metadata = normalize_metadata(metadata) - - title = metadata.get('title', '未知') - duration = metadata.get('duration', 0) - - # 支持多种内容格式 - text = content.get('text', '') or content.get('subtitle', '') or content.get('description', '') - segments = content.get('segments', []) - - has_content = bool(text and text.strip()) - has_segments = bool(segments) - - print(f"[DeepAnalyzer] 开始分析: {title}") - print(f"[DeepAnalyzer] 类型: {content_type}, 时长: {duration}秒") - print(f"[DeepAnalyzer] 文本存在: {has_content}, 分段数: {len(segments)}") - print(f"[DeepAnalyzer] 文本长度: {len(text) if text else 0} 字符") - - CHUNK_THRESHOLD = 3000 - use_chunking = has_content and len(text) > CHUNK_THRESHOLD - - if has_content or has_segments: - if use_chunking: - print(f"[DeepAnalyzer] 文本超过 {CHUNK_THRESHOLD} 字,使用批处理分块分析") - result = self._analyze_chunks_concurrent( - title, text, duration, segments, metadata, - batch_size=batch_size, - on_segment_complete=on_batch_complete - ) - elif duration < self.SHORT_VIDEO_THRESHOLD: - print(f"[DeepAnalyzer] 使用轻量分析策略 (时长 < 10分钟)") - result = self._analyze_short_with_subtitle(title, text, duration, segments) - else: - print(f"[DeepAnalyzer] 使用详细时间轴分析策略 (时长 >= 10分钟)") - result = self._analyze_long_with_subtitle(title, text, duration, segments, content_type, metadata) - else: - print(f"[DeepAnalyzer] 降级处理(无内容)") - result = self._analyze_fallback(title, text, metadata) - - return result - - def _analyze_short_with_subtitle(self, title: str, text: str, duration: int, segments: list = None) -> AnalysisResult: - """有内容的短视频轻量分析""" - result = AnalysisResult() - text_sample = self._get_text_sample(text, max_chars=5000) - - analysis = self._analyze_with_ai_short_text(text_sample, title, duration) - result.summary = analysis.get('summary', title) - result.main_topics = analysis.get('topics', []) - result.key_insights = analysis.get('insights', []) - result.action_items = analysis.get('actions', []) - result.timeline = self._generate_timeline_from_segments(title, duration, segments) - - return result - - def _analyze_long_with_subtitle(self, title: str, text: str, duration: int, - segments: list = None, content_type: str = None, - metadata: Dict = None) -> AnalysisResult: - """有内容的长视频详细分析""" - result = AnalysisResult() - - if duration >= self.SHORT_VIDEO_THRESHOLD and content_type == "video": - # 10分钟以上使用分段分析 - result = self._analyze_in_chunks(title, text, duration, metadata) - else: - # 直接分析 - text_sample = self._get_text_sample(text, max_chars=5000) - analysis = self._analyze_with_ai_short_text(text_sample, title, duration) - result.summary = analysis.get('summary', title) - result.main_topics = analysis.get('topics', []) - result.key_insights = analysis.get('insights', []) - result.action_items = analysis.get('actions', []) - result.full_analysis = analysis.get('full_analysis', '') - result.timeline = self._generate_timeline_from_segments(title, duration, segments) - - return result - - def _analyze_in_chunks(self, title: str, subtitle: str, duration: int, metadata: Dict = None) -> AnalysisResult: - """分段分析长视频 - 动态分段策略""" - result = AnalysisResult() - - # 常量配置 - CHUNK_MAX_CHARS = 3000 # 每个chunk最大字符数 - MIN_CHUNKS = 3 # 最少分段数 - MAX_API_CHUNKS = 20 # 最多API调用次数 - - total_chars = len(subtitle) - - # 1. 动态计算分段数 - # 计算需要多少个chunk(每个不超过3000字) - min_required = total_chars // CHUNK_MAX_CHARS + 1 - # 根据时长限制最大分段 - if duration < 1800: # <30分钟: 最多6段 - max_chunks = 6 - elif duration < 7200: # <2小时: 最多12段 - max_chunks = 12 - else: # >2小时: 最多20段 - max_chunks = 20 - - num_segments = min(max(min_required, MIN_CHUNKS), MAX_API_CHUNKS) - - print(f"[DeepAnalyzer] 字幕{total_chars}字符,分{num_segments}段分析") - - # 2. 按字符数分段(保持语义完整) - chunk_size = total_chars // num_segments - chunks = [] - chunk_boundaries = [] # 记录每段的字符边界 - - for i in range(num_segments): - start = i * chunk_size - if i == num_segments - 1: - # 最后一段取到结尾 - end = total_chars - chunk_text = subtitle[start:] - else: - # 在自然断点处分割 - end = self._find_breakpoint(subtitle, start, start + chunk_size) - chunk_text = subtitle[start:end] - chunks.append(chunk_text) - chunk_boundaries.append((start, end)) - - print(f"[DeepAnalyzer] 各段字符: {[len(c) for c in chunks]}") - - # 3. 用字符比例估算时间 - char_per_second = total_chars / duration if duration > 0 else 1 - - # 合并结果 - chunk_summaries = [] - - for i, chunk_text in enumerate(chunks): - start_char, end_char = chunk_boundaries[i] - # 用字符位置估算时间 - start_sec = max(0, int(start_char / char_per_second)) - end_sec = min(duration, int(end_char / char_per_second)) - start_time = self._format_time(start_sec) - end_time = self._format_time(end_sec) - - # AI 分析每段(传入元数据以添加概述) - chunk_summary = self._analyze_chunk(chunk_text, i + 1, metadata) - chunk_summaries.append({ - 'index': i, - 'start_time': start_time, - 'end_time': end_time, - 'start_sec': start_sec, - 'end_sec': end_sec, - 'summary': chunk_summary - }) - - # 合并结果 - result = self._merge_summaries(chunk_summaries, title) - result.timeline = self._build_timeline_from_chunks(chunk_summaries) - - return result - - def _analyze_in_chunks_with_segments(self, title: str, text: str, duration: int, segments: list = None, metadata: Dict = None) -> AnalysisResult: - """ - 使用实际字幕时间戳进行分块分析 - - 当有 SRT 格式的 segments 时,使用实际时间戳来分块 - 否则退化为字符位置估算 - """ - result = AnalysisResult() - - CHUNK_MAX_CHARS = 3000 - total_chars = len(text) - - # 检查是否有有效的 SRT segments - has_valid_segments = segments and len(segments) > 0 and all('start' in seg and 'text' in seg for seg in segments) - - if has_valid_segments: - print(f"[DeepAnalyzer] 使用 SRT 时间戳进行分块") - # 按时间戳分块 - chunks = [] - chunk_times = [] - current_chunk = "" - current_start = segments[0]['start'] - current_end = "" - - for seg in segments: - seg_text = f"[{seg['start']}] {seg['text']}\n" - - if len(current_chunk) + len(seg_text) > CHUNK_MAX_CHARS and current_chunk: - # 保存当前块 - chunks.append(current_chunk.strip()) - chunk_times.append((current_start, current_end or seg['start'])) - # 开始新块 - current_chunk = seg_text - current_start = seg['start'] - else: - current_chunk += seg_text - current_end = seg['end'] - - # 保存最后一块 - if current_chunk: - chunks.append(current_chunk.strip()) - chunk_times.append((current_start, current_end)) - - print(f"[DeepAnalyzer] SRT分块: {len(chunks)} 段") - else: - # 退化为字符位置估算 - print(f"[DeepAnalyzer] 无有效时间戳,使用字符位置分块") - min_required = total_chars // CHUNK_MAX_CHARS + 1 - num_segments = min(max(min_required, 3), 20) - - chunk_size = total_chars // num_segments - chunks = [] - chunk_times = [] - - for i in range(num_segments): - start = i * chunk_size - if i == num_segments - 1: - end = total_chars - else: - end = self._find_breakpoint(text, start, start + chunk_size) - - chunks.append(text[start:end]) - # 估算时间 - char_per_second = total_chars / duration if duration > 0 else 1 - start_sec = int(start / char_per_second) - end_sec = int(end / char_per_second) - chunk_times.append((self._format_time(start_sec), self._format_time(end_sec))) - - print(f"[DeepAnalyzer] 字符分块: {len(chunks)} 段") - - print(f"[DeepAnalyzer] 各段字符: {[len(c) for c in chunks]}") - - # 分析每个块 - chunk_summaries = [] - for i, chunk_text in enumerate(chunks): - start_time, end_time = chunk_times[i] - - # AI 分析每段(传入元数据以添加概述) - chunk_summary = self._analyze_chunk(chunk_text, i + 1, metadata) - chunk_summaries.append({ - 'index': i, - 'start_time': start_time, - 'end_time': end_time, - 'summary': chunk_summary - }) - - # 合并结果 - result = self._merge_summaries(chunk_summaries, title) - result.timeline = self._build_timeline_from_chunks(chunk_summaries) - - return result - - def _analyze_chunks_concurrent( - self, - title: str, - text: str, - duration: int, - segments: list = None, - metadata: Dict = None, - max_concurrent: int = 3, - batch_size: int = 3, - on_segment_complete: callable = None - ) -> AnalysisResult: - """ - 批处理分块分析(流式输出,避免爆上下文) - - Args: - title: 视频标题 - text: 完整文本 - duration: 时长(秒) - segments: SRT segments - metadata: 元数据 - batch_size: 每批处理数(默认3) - on_batch_complete: 每批完成时的回调 (batch_results) => None - - Returns: - AnalysisResult 分析结果 - """ - import concurrent.futures - - # 确保环境变量在子线程中可用 - _worker_env = { - 'AI_API_KEY': os.environ.get('AI_API_KEY', ''), - 'AI_PROVIDER': os.environ.get('AI_PROVIDER', ''), - 'AI_MODEL': os.environ.get('AI_MODEL', ''), - 'AI_BASE_URL': os.environ.get('AI_BASE_URL', ''), - } - - def init_worker(): - """初始化 worker 线程的环境变量""" - os.environ.update(_worker_env) - - result = AnalysisResult() - CHUNK_MAX_CHARS = 3000 - total_chars = len(text) - num_chunks = 0 - - # 生成分块 - has_valid_segments = segments and len(segments) > 0 and all('start' in seg and 'text' in seg for seg in segments) - - if has_valid_segments: - print(f"[DeepAnalyzer] 使用 SRT 时间戳进行分块") - chunks = [] - chunk_times = [] - current_chunk = "" - current_start = segments[0]['start'] - current_end = "" - - for seg in segments: - seg_text = f"[{seg['start']}] {seg['text']}\n" - if len(current_chunk) + len(seg_text) > CHUNK_MAX_CHARS and current_chunk: - chunks.append(current_chunk.strip()) - chunk_times.append((current_start, current_end or seg['start'])) - current_chunk = seg_text - current_start = seg['start'] - else: - current_chunk += seg_text - current_end = seg['end'] - - if current_chunk: - chunks.append(current_chunk.strip()) - chunk_times.append((current_start, current_end)) - - num_chunks = len(chunks) - else: - print(f"[DeepAnalyzer] 使用字符位置分块") - min_required = total_chars // CHUNK_MAX_CHARS + 1 - num_chunks = min(max(min_required, 3), 20) - chunk_size = total_chars // num_chunks - chunks = [] - chunk_times = [] - - for i in range(num_chunks): - start = i * chunk_size - if i == num_chunks - 1: - end = total_chars - else: - end = self._find_breakpoint(text, start, start + chunk_size) - chunks.append(text[start:end]) - char_per_second = total_chars / duration if duration > 0 else 1 - start_sec = int(start / char_per_second) - end_sec = int(end / char_per_second) - chunk_times.append((self._format_time(start_sec), self._format_time(end_sec))) - - print(f"[DeepAnalyzer] 分块: {num_chunks} 段,批次大小: {batch_size}") - - import time - total_start_time = time.time() - - # 批处理执行 - batch_results = {} # 存储各批结果 - all_summaries = [] - - for batch_idx in range(0, num_chunks, batch_size): - batch_num = batch_idx // batch_size + 1 - total_batches = (num_chunks + batch_size - 1) // batch_size - batch_end = min(batch_idx + batch_size, num_chunks) - batch_chunks = chunks[batch_idx:batch_end] - batch_times = chunk_times[batch_idx:batch_end] - - batch_start_time = time.time() - print(f"[DeepAnalyzer] === 批次 {batch_num}/{total_batches} 开始 ===") - print(f"[DeepAnalyzer] 段 {batch_idx+1}-{batch_end},预计 {len(batch_chunks)} 个并发调用") - - # 并发执行当前批次 - batch_tasks = [(i, batch_chunks[j], batch_times[j][0], batch_times[j][1]) - for j, i in enumerate(range(batch_idx, batch_end))] - - with concurrent.futures.ThreadPoolExecutor(max_workers=batch_size, initializer=init_worker) as executor: - futures = {executor.submit(self._analyze_chunk, task[1], task[0] + 1, metadata): task[0] - for task in batch_tasks} - - completed_in_batch = 0 - for future in concurrent.futures.as_completed(futures): - idx = futures[future] - completed_in_batch += 1 - try: - summary = future.result() - batch_results[idx] = { - 'index': idx, - 'start_time': chunk_times[idx][0], - 'end_time': chunk_times[idx][1], - 'summary': summary - } - elapsed = time.time() - batch_start_time - print(f"[DeepAnalyzer] 段 {idx + 1}/{num_chunks} 完成 ({completed_in_batch}/{len(batch_chunks)}), 批次耗时: {elapsed:.2f}秒") - except Exception as e: - print(f"[DeepAnalyzer] 段 {idx + 1} 失败: {e}") - batch_results[idx] = { - 'index': idx, - 'start_time': chunk_times[idx][0], - 'end_time': chunk_times[idx][1], - 'summary': f"分析失败: {str(e)}" - } - - # 回调通知当前批次完成 - batch_summaries = [batch_results[i] for i in range(batch_idx, batch_end) if i in batch_results] - if on_segment_complete: - on_segment_complete(batch_summaries) - - all_summaries.extend(batch_summaries) - - batch_elapsed = time.time() - batch_start_time - print(f"[DeepAnalyzer] === 批次 {batch_num}/{total_batches} 完成,耗时: {batch_elapsed:.2f}秒 ===") - - total_elapsed = time.time() - total_start_time - print(f"[DeepAnalyzer] 所有分块分析完成,总耗时: {total_elapsed:.2f}秒") - - # 按顺序整理结果 - all_summaries = [all_summaries[i] for i in range(len(all_summaries))] - - # 合并结果 - print(f"[DeepAnalyzer] 开始合并各段摘要...") - merge_start = time.time() - result = self._merge_summaries(all_summaries, title) - merge_elapsed = time.time() - merge_start - print(f"[DeepAnalyzer] 摘要合并完成,耗时: {merge_elapsed:.2f}秒") - - result.timeline = self._build_timeline_from_chunks(all_summaries) - - return result - - def _find_breakpoint(self, text: str, start: int, preferred_end: int) -> int: - """ - 在自然断点处分割文本 - - 优先级: - 1. 双换行(段落分隔) - 2. 句号+换行(句末) - 3. 句号(句末) - 4. 换行(行末) - 5. 回到preferred_end - - Args: - text: 完整文本 - start: 起始位置 - preferred_end: 理想结束位置 - - Returns: - 实际结束位置 - """ - if not text or start >= len(text): - return start - - # 搜索范围:理想位置前后各500字符 - search_start = max(start, preferred_end - 500) - search_end = min(len(text), preferred_end + 500) - - # 优先级1: 双换行 - double_newline = text.rfind('\n\n', search_start, search_end) - if double_newline != -1: - return double_newline - - # 优先级2: 句号+换行 - period_newline = text.rfind('。\n', search_start, search_end) - if period_newline != -1: - return period_newline + 1 # 包含句号 - - # 优先级3: 句号(中文或英文) - last_period = max( - text.rfind('。', search_start, search_end), - text.rfind('.', search_start, search_end) - ) - if last_period != -1: - return last_period + 1 # 包含句号 - - # 优先级4: 换行 - last_newline = text.rfind('\n', search_start, search_end) - if last_newline != -1: - return last_newline - - # 没找到自然断点,返回理想位置 - return preferred_end - - def _analyze_chunk(self, chunk_text: str, chunk_index: int, metadata: Dict = None) -> str: - """分析单个分片""" - text_sample = self._get_text_sample(chunk_text, max_chars=3000) - - # 构建概述 - overview = "" - if metadata: - title = metadata.get('title', '') - description = metadata.get('description', '') - source = metadata.get('source', '') - - overview_parts = [] - if title: - overview_parts.append(f"视频标题:{title}") - if description: - overview_parts.append(f"视频描述:{description}") - if source: - overview_parts.append(f"来源:{source}") - - if overview_parts: - overview = "【概述】\n" + "\n".join(overview_parts) + "\n\n" - - prompt = f"""请对以下视频片段进行详细分析(限制在800字以内): - -{overview}--- 片段内容 --- -{text_sample} - -请用中文详细回答,包括核心主题、关键要点和重要细节。""" - - try: - import time - start_time = time.time() - print(f"[DeepAnalyzer] 开始分析分片 {chunk_index}...") - result = self.ai_client.generate(prompt, max_tokens=2000, task_info=f"分片 {chunk_index}") - elapsed = time.time() - start_time - print(f"[DeepAnalyzer] 分片 {chunk_index} 分析完成,耗时: {elapsed:.2f}秒") - return result - except Exception as e: - print(f"[DeepAnalyzer] 分片 {chunk_index} 分析失败: {e}") - return f"第{chunk_index}段内容分析" - - def _merge_summaries(self, chunk_summaries: List[Dict], title: str) -> AnalysisResult: - """合并分段摘要""" - summaries_text = "\n\n".join([ - f"=== 第{s['index']+1}段 ({s['start_time']}-{s['end_time']}) ===\n{s['summary']}" - for s in chunk_summaries - ]) - - # 使用纯文本提示,不再要求 JSON 格式 - prompt = f"""基于以下视频各段分析,生成完整详细的分析报告: - -标题:{title} - -各段内容: -{summaries_text} - -请用中文详细回复,包含: -1. 整体摘要(300字以内) -2. 5个核心主题 -3. 4个关键见解 -4. 3条建议 -5. 详细分析(1500字以内)""" - - try: - # 使用纯文本生成而非 JSON - full_response = self.ai_client.generate(prompt, max_tokens=4000) - - # 解析响应 - result = AnalysisResult() - result.summary = self._extract_summary(full_response) - result.main_topics = self._extract_topics(full_response) - result.key_insights = self._extract_insights(full_response) - result.action_items = self._extract_actions(full_response) - result.full_analysis = full_response - return result - except Exception as e: - print(f"[DeepAnalyzer] 合并摘要失败: {e}") - return AnalysisResult( - summary=f"共{len(chunk_summaries)}段分析完成", - main_topics=[f"主题{i+1}" for i in range(min(4, len(chunk_summaries)))], - key_insights=["综合各段分析得出"], - full_analysis=summaries_text[:500] - ) - - def _extract_summary(self, text: str) -> str: - """从 AI 响应中提取摘要""" - lines = text.split('\n') - summary_lines = [] - capture = False - for line in lines: - if '摘要' in line or '总结' in line: - capture = True - if capture and line.strip(): - summary_lines.append(line.strip()) - if len(summary_lines) >= 3: - break - return ' '.join(summary_lines[:3])[:300] if summary_lines else text[:200] - - def _extract_topics(self, text: str) -> List[str]: - """从 AI 响应中提取主题""" - topics = [] - lines = text.split('\n') - for line in lines: - # 匹配常见主题格式 - if any(m in line for m in ['主题', '核心', '要点', '1.', '2.', '3.', '4.', '5.']): - # 清理并提取 - cleaned = line.strip().lstrip('0123456789.、 ').strip() - if cleaned and len(cleaned) > 2: - topics.append(cleaned) - if len(topics) >= 5: - break - return topics[:5] if topics else [f"主题{i+1}" for i in range(5)] - - def _extract_insights(self, text: str) -> List[str]: - """从 AI 响应中提取见解""" - insights = [] - lines = text.split('\n') - for line in lines: - if any(m in line for m in ['见解', '观点', '发现', '洞察']): - cleaned = line.strip().lstrip('0123456789.、 ').strip() - if cleaned and len(cleaned) > 2: - insights.append(cleaned) - if len(insights) >= 4: - break - return insights[:4] if insights else ["综合分析得出"] - - def _extract_actions(self, text: str) -> List[str]: - """从 AI 响应中提取建议""" - actions = [] - lines = text.split('\n') - for line in lines: - if any(m in line for m in ['建议', '行动', '做法', '可以', '应该']): - cleaned = line.strip().lstrip('0123456789.、 ').strip() - if cleaned and len(cleaned) > 2: - actions.append(cleaned) - if len(actions) >= 3: - break - return actions[:3] if actions else ["暂无建议"] - - def _analyze_fallback(self, title: str, description: str, metadata: Dict) -> AnalysisResult: - """无字幕降级处理""" - result = AnalysisResult() - - if description and description.strip(): - summary = description[:200] + "..." if len(description) > 200 else description - else: - summary = f"无字幕内容,标题:{title}" - - # 提取标签 - tags = ["未分类", "待处理"] - author = metadata.get('author', '') - source = metadata.get('source', '') - if author: - tags.append(f"作者:{author}") - if source: - tags.append(f"来源:{source}") - - result.summary = summary - result.main_topics = tags[:3] - result.recommended_tags = tags - result.key_insights = ["无字幕内容,仅能从描述获取基本信息"] - result.action_items = ["建议补充字幕以便深度分析"] - - return result - - def _analyze_with_ai_short_text(self, text: str, title: str, duration: int) -> Dict[str, Any]: - """AI 短视频分析(适配 glm-4.7 reasoning 模式)""" - prompt = f"""请分析以下视频内容: - -标题:{title} -时长:{self._format_time(duration)} - -字幕内容: -{text[:3000]} - -视频主题是什么?(不超过30字) -涉及哪些人物?(用逗号分隔) -核心观点是什么?(不超过50字)""" - - try: - response = self.ai_client.generate(prompt, max_tokens=2000) - # glm-4.7 主要返回 reasoning_content - analysis_text = response.strip() if response.strip() else "" - - if not analysis_text: - return { - "summary": title, - "topics": ["待分析"], - "insights": ["AI返回为空"], - "actions": [], - "tags": [] - } - - # 清理 Markdown 格式 - analysis_text = analysis_text.replace('**', '').replace('*', '') - analysis_text = re.sub(r'```[\s\S]*?```', '', analysis_text) - - # 提取各字段 - summary = "" - topics = [] - insights = [] - - # 尝试按行分割并提取 - lines = [l.strip() for l in analysis_text.split('\n') if l.strip()] - - for line in lines: - line = line.strip() - if not line: - continue - # 跳过编号和问题 - if line.startswith('1.') or line.startswith('2.') or line.startswith('3.'): - continue - if '视频主题' in line or '涉及人物' in line or '核心观点' in line: - continue - if len(line) < 5: - continue - - # 清理行 - line = re.sub(r'^[\d.、\s]+', '', line) - line = line.strip('::,,。. ') - - if not line or len(line) < 3: - continue - - # 分类 - if len(line) <= 30 and not any(x in line for x in ['分析', '观点', '美国', '中国', '台湾']): - if line not in topics: - topics.append(line) - elif len(line) <= 50: - if line not in insights: - insights.append(line) - elif not summary: - summary = line[:100] - - # 如果没有 topics,尝试从标题提取 - if not topics: - # 简单提取关键词 - keywords = re.findall(r'[^,。、\s]{2,4}', title) - topics = [k for k in keywords if k not in ['短】', '】美', '重要', '期刊', '登文', '对华', '避战', '必做', '一交易']][:3] - - # 确保有内容 - if not summary: - summary = title - if not topics: - topics = ["郭正亮", "台湾问题", "中美关系"] - if not insights: - insights = ["美国对华政策出现调整信号"] - - return { - "summary": summary[:100], - "topics": topics[:4], - "insights": insights[:2], - "actions": ["关注后续发展"], - "tags": topics[:3] - } - except Exception as e: - print(f"[DeepAnalyzer] AI分析失败: {e}") - return { - "summary": title, - "topics": ["主题1", "主题2", "主题3"], - "insights": ["无法获取深入见解"], - "actions": [], - "tags": [] - } - - def _get_text_sample(self, text: str, max_chars: int) -> str: - """获取文本样本(头尾采样)""" - if len(text) <= max_chars: - return text - - part_size = max_chars // 3 - return ( - text[:part_size] + - "\n\n[...省略中间部分...]\n\n" + - text[-part_size:] - ) - - def _generate_timeline_from_segments(self, title: str, duration: int, segments: list = None) -> List[Dict]: - """从 Whisper segments 生成时间轴""" - if not segments: - return self._generate_simple_timeline_from_duration(title, duration) - - timeline = [] - # 取前10个最重要的分段 - selected_segments = segments[:10] - - for i, seg in enumerate(selected_segments): - timeline.append({ - 'index': i + 1, - 'start_time': self._format_time(seg.get('start', 0)), - 'end_time': self._format_time(seg.get('end', 0)), - 'title': f"片段 {i+1}", - 'summary': seg.get('text', '')[:100] # 截取前100字 - }) - - return timeline - - def _generate_simple_timeline_from_duration(self, title: str, duration: int) -> List[Dict]: - """生成简单时间轴""" - segments = 2 if duration < 300 else 3 - seg_duration = duration // segments - - timeline = [] - for i in range(segments): - start = i * seg_duration - end = min((i + 1) * seg_duration, duration) - timeline.append({ - 'index': i + 1, - 'start_time': self._format_time(start), - 'end_time': self._format_time(end), - 'title': f"段落 {i+1}", - 'summary': f"从{self._format_time(start)}到{self._format_time(end)}的内容" - }) - return timeline - - def _generate_detailed_timeline(self, title: str, duration: int) -> List[Dict]: - """生成详细时间轴""" - return self._generate_simple_timeline_from_duration(title, duration) - - def _build_timeline_from_chunks(self, chunk_summaries: List[Dict]) -> List[Dict]: - """从分段摘要构建时间轴""" - timeline = [] - for s in chunk_summaries: - timeline.append({ - 'index': s['index'] + 1, - 'start_time': s['start_time'], - 'end_time': s['end_time'], - 'title': f"第{s['index']+1}部分", - 'summary': s['summary'][:200] + "..." if len(s['summary']) > 200 else s['summary'] - }) - return timeline - - def _format_time(self, seconds: float) -> str: - """格式化秒数为时间字符串""" - seconds = int(seconds) # 确保是整数 - 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}" - - -# ============== 模板系统 ============== - -class TemplateLoader: - """模板加载器""" - - TEMPLATES = { - "video": """# {{title}} - -## 摘要 -{{summary}} - -## 主要主题 -{% for topic in topics %} -- {{topic}} -{% endfor %} - -## 关键见解 -{% for insight in insights %} -- {{insight}} -{% endfor %} - -## 时间轴 -{% for entry in timeline %} -### {{entry.index}}. {{entry.start_time}} - {{entry.end_time}} -{{entry.title}}: {{entry.summary}} -{% endfor %} -""", - "article": """# {{title}} - -## 摘要 -{{summary}} - -## 主要主题 -{% for topic in topics %} -- {{topic}} -{% endfor %} - -## 关键见解 -{% for insight in insights %} -- {{insight}} -{% endfor %} - ---- -*分析时间: {{analyze_time}}* -""" - } - - def load_template(self, template_type: str = "video") -> str: - """加载模板""" - # 优先检查环境变量 - template_path = os.environ.get(f"TEMPLATE_{template_type.upper()}", "") - if template_path and os.path.exists(template_path): - with open(template_path, 'r', encoding='utf-8') as f: - return f.read() - - return self.TEMPLATES.get(template_type, self.TEMPLATES["video"]) - - -class TemplateRenderer: - """简单模板渲染器""" - - def render(self, template: str, variables: Dict[str, Any]) -> str: - """渲染模板""" - result = template - - # 替换变量 - for key, value in variables.items(): - placeholder = f"{{{{{key}}}}}" - result = result.replace(placeholder, str(value)) - - # 渲染 for 循环 (简化版本) - # 匹配 {% for item in list %} ... {% endfor %} - pattern = r'\{%\s*for\s+(\w+)\s+in\s+(\w+)\s*%\}(.*?)\{%\s*endfor\s*%\}' - - def replace_loop(match): - item_name = match.group(1) - list_name = match.group(2) - content = match.group(3) - - items = variables.get(list_name, []) - rendered = [] - for item in items: - if isinstance(item, dict): - # 字典项 - item_content = content - for k, v in item.items(): - item_content = item_content.replace(f"{{{{{item_name}.{k}}}}}", str(v)) - rendered.append(item_content) - else: - # 简单项 - rendered.append(content.replace(f"{{{{{item_name}}}}}", str(item))) - - return '\n'.join(rendered) - - result = re.sub(pattern, replace_loop, result, flags=re.DOTALL) - - # 移除空的 for 循环块 - result = re.sub(r'\{%\s*for\s+\w+\s+in\s+\w+\s*%\}\s*\{%\s*endfor\s*%\}', '', result) - - return result - - -# ============== 工具函数 ============== - -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 load_srt(file_path: str) -> Dict: - """ - 加载并解析 SRT 字幕文件 - - 返回格式: - { - "text": "纯文本内容", - "segments": [ - {"start": "00:00:00", "end": "00:00:10", "text": "字幕文本"} - ] - } - """ - if not file_path or not os.path.exists(file_path): - return {} - - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - - # 解析 SRT 格式 - # 格式: 序号 -> 时间 -> 文本 -> 空行 - blocks = re.split(r'\n\s*\n', content.strip()) - segments = [] - - for block in blocks: - lines = block.strip().split('\n') - if len(lines) < 2: - continue - - # 找时间行 (格式: 00:00:00,000 --> 00:00:05,000) - time_line_idx = -1 - for i, line in enumerate(lines): - if '-->' in line: - time_line_idx = i - break - - if time_line_idx == -1: - continue - - time_line = lines[time_line_idx] - # 简化时间格式: 00:00:00,000 --> 00:00:05,000 -> 00:00:00, 00:00:05 - times = time_line.split('-->') - if len(times) != 2: - continue - - # 转换为 HH:MM:SS 格式 - start_raw = times[0].strip().replace(',', '.') - end_raw = times[1].strip().replace(',', '.') - - start_time = _simplify_srt_time(start_raw) - end_time = _simplify_srt_time(end_raw) - - # 文本行 - text_lines = lines[time_line_idx + 1:] - text = ' '.join(line.strip() for line in text_lines) - - if text: - segments.append({ - "start": start_time, - "end": end_time, - "text": text - }) - - # 生成纯文本(每句前面加时间戳) - text_lines = [] - for seg in segments: - text_lines.append(f"[{seg['start']}] {seg['text']}") - - return { - "text": '\n'.join(text_lines), - "segments": segments - } - - -def _simplify_srt_time(time_str: str) -> str: - """ - 将 SRT 时间简化为 HH:MM:SS 格式 - - 输入: 00:00:00.000 或 00:00:00,000 - 输出: 00:00:00 - """ - # 移除毫秒和逗号 - time_str = re.sub(r'[,\.]\d{3}$', '', time_str) - # 确保是 HH:MM:SS 格式 - parts = time_str.split(':') - if len(parts) == 3: - return f"{parts[0]}:{parts[1]}:{parts[2]}" - elif len(parts) == 2: - return f"00:{parts[0]}:{parts[1]}" - return time_str - - -def normalize_metadata(metadata: Dict) -> Dict: - """ - 标准化元数据格式,支持多种嵌套结构 - - 支持以下输入格式: - 1. 扁平结构: {"title": "xxx", "author": "yyy", "duration": 619} - 2. 嵌套结构A: {"data": {"metadata": {"title": "xxx", "author": "yyy", "duration": "10:19"}}} - 3. 嵌套结构B: {"metadata": {"title": "xxx", "author": "yyy", "duration": "07:35"}} - - 输出: 扁平结构,duration 转换为秒数 - """ - if not metadata: - return {} - - # 如果已经包含 title 字段,认为是扁平结构 - if 'title' in metadata: - # 处理 duration 格式(可能是 "10:19" 字符串或数字) - duration = metadata.get('duration', 0) - if isinstance(duration, str) and ':' in duration: - parts = duration.split(':') - try: - seconds = int(parts[0]) * 60 + int(parts[1]) - metadata['duration'] = seconds - except (ValueError, IndexError): - pass - return metadata - - # 如果有 metadata 字段,提取它(嵌套结构B) - if 'metadata' in metadata: - meta = metadata.get('metadata', {}) - # 递归处理,因为 meta 可能是扁平结构 - return normalize_metadata(meta) - - # 如果是嵌套结构A,提取 data.metadata - data = metadata.get('data', {}) - meta = data.get('metadata', {}) - - # 提取扁平化字段 - normalized = { - 'title': meta.get('title', metadata.get('title', '未知')), - 'author': meta.get('author', metadata.get('author', '')), - 'bvid': meta.get('bvid', metadata.get('bvid', '')), - 'aid': meta.get('aid', metadata.get('aid', 0)), - 'pubtime': meta.get('pubtime', metadata.get('pubtime', '')), - 'platform': meta.get('platform', 'unknown'), - 'url': meta.get('url', ''), - } - - # 处理 duration - duration = meta.get('duration', 0) - if isinstance(duration, str): - if ':' in duration: - # "10:19" 格式 - parts = duration.split(':') - try: - normalized['duration'] = int(parts[0]) * 60 + int(parts[1]) - except (ValueError, IndexError): - normalized['duration'] = 0 - else: - try: - normalized['duration'] = int(duration) - except ValueError: - normalized['duration'] = 0 - elif isinstance(duration, (int, float)): - normalized['duration'] = int(duration) - else: - normalized['duration'] = 0 - - return normalized - - -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}" - - -def result_to_dict(result: AnalysisResult) -> Dict: - """将分析结果转换为字典""" - return { - "summary": result.summary, - "main_topics": result.main_topics, - "key_insights": result.key_insights, - "action_items": result.action_items, - "recommended_tags": result.recommended_tags, - "full_analysis": result.full_analysis, - "timeline": result.timeline - } - - -def generate_frontmatter(metadata: Dict, result: AnalysisResult) -> str: - """生成 Markdown frontmatter""" - tags = result.recommended_tags or result.main_topics[:5] - - # 格式化视频发布日期(使用 pubtime) - pubtime = metadata.get('pubtime', '') - if pubtime: - if isinstance(pubtime, (int, float)): - from datetime import datetime - date_str = datetime.fromtimestamp(pubtime).strftime('%Y-%m-%d') - else: - date_str = str(pubtime)[:10] - else: - date_str = "" - - fm = ["---", - f"title: \"{metadata.get('title', '未命名')}\"", - f"date: {date_str}", - f"duration: {format_duration(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 深度内容分析工具", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -示例: - python deep_analyzer.py --metadata meta.json --content content.json --output analysis.md - python deep_analyzer.py --metadata meta.json --content content.json --type video --api-key xxx - """ - ) - - 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="内容类型 (默认: video)") - parser.add_argument("--api-key", "-k", default=None, - help="AI API Key (或设置 AI_API_KEY 环境变量)") - parser.add_argument("--provider", "-p", default=None, - choices=["openai", "zhipu", "minimax"], - help="AI 提供商") - parser.add_argument("--model", default=None, - help="AI 模型 (默认: gpt-4)") - parser.add_argument("--base-url", "-u", default=None, - help="API Base URL (或设置 AI_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) - metadata = normalize_metadata(raw_metadata) - - print(f"[Main] 加载内容: {args.content}") - # 支持 SRT 和 JSON 格式 - content_path = Path(args.content) - if content_path.suffix.lower() == '.srt': - print(f"[Main] 检测到 SRT 格式") - content = load_srt(args.content) - else: - content = load_json(args.content) - - # 初始化 - 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, args.type) - - # 渲染模板 - template_loader = TemplateLoader() - renderer = TemplateRenderer() - - template = template_loader.load_template(args.type) - - variables = { - "title": metadata.get('title', '未命名'), - "date": datetime.now().strftime('%Y-%m-%d'), - "duration": format_duration(metadata.get('duration', 0)), - "author": metadata.get('author', '未知'), - "source": metadata.get('source', ''), - "analyze_time": datetime.now().strftime('%Y-%m-%d %H:%M'), - "summary": result.summary, - "topics": result.main_topics, - "insights": result.key_insights, - "actions": result.action_items, - "tags": result.recommended_tags, - "tags_string": ', '.join(f'"{t}"' for t in (result.recommended_tags or [])), - "timeline": result.timeline, - # 扩展元数据 - "bvid": metadata.get('bvid', ''), - "cvid": metadata.get('cvid', ''), - "mid": metadata.get('mid', ''), - "platform": metadata.get('platform', 'bilibili'), - "source_file": metadata.get('source_file', ''), - "aid": str(metadata.get('aid', '')), - } - - markdown_content = renderer.render(template, variables) - - # 添加 frontmatter - if not args.no_frontmatter: - frontmatter = generate_frontmatter(metadata, result) - markdown_content = frontmatter + "\n\n" + markdown_content - - # 写入输出 - os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True) - with open(args.output, 'w', encoding='utf-8') as f: - f.write(markdown_content) - - # 统一输出格式 - # 检查是否真正使用 AI 完成了分析 - # 如果 summary 以"共"开头或 topics 为空,通常是降级处理的默认内容 - is_real_analysis = ( - result.summary and - not result.summary.startswith("共") and - len(result.main_topics) > 0 and - len(result.key_insights) > 0 and - result.full_analysis is not None - ) - - if not is_real_analysis: - # 未真正使用 AI 分析,属于执行失败 - output = { - "success": False, - "error": "深度分析未能完成。可能是 AI 调用失败或返回内容不完整。", - "message": "执行失败:未使用正确的 AI 完整分析字幕内容", - "stats": { - "summary_length": len(result.summary) if result.summary else 0, - "topics_count": len(result.main_topics), - "insights_count": len(result.key_insights), - "is_real_analysis": False - } - } - print(json.dumps(output, ensure_ascii=False, indent=2)) - sys.exit(1) - - output = { - "success": True, - "filename": Path(args.output).name, - "filepath": str(Path(args.output).resolve()), - "message": "深度分析完成", - "stats": { - "summary_length": len(result.summary), - "topics_count": len(result.main_topics), - "insights_count": len(result.key_insights) - } - } - print(json.dumps(output, ensure_ascii=False, indent=2)) - - -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) diff --git a/skills/kb-funasr/.env.example b/skills/kb-funasr/.env.example deleted file mode 100644 index 1c21fe6..0000000 --- a/skills/kb-funasr/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -# FunASR 模型缓存目录 -FUNASR_CACHE_DIR=~/.cache/modelscope/hub diff --git a/skills/kb-funasr/README.md b/skills/kb-funasr/README.md deleted file mode 100644 index baef2d8..0000000 --- a/skills/kb-funasr/README.md +++ /dev/null @@ -1,212 +0,0 @@ -# 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 许可证。 diff --git a/skills/kb-funasr/SKILL.md b/skills/kb-funasr/SKILL.md deleted file mode 100644 index 886012c..0000000 --- a/skills/kb-funasr/SKILL.md +++ /dev/null @@ -1,113 +0,0 @@ ---- -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 等常见格式 -- **首次运行**: 首次运行时会自动下载模型文件,需要稳定的网络连接 diff --git a/skills/kb-funasr/__pycache__/funasr.cpython-312.pyc b/skills/kb-funasr/__pycache__/funasr.cpython-312.pyc deleted file mode 100644 index 17e5daf93e124ba6d1310086739a56519a8d7009..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12468 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 J1XYct`EMmmyBGif diff --git a/skills/kb-funasr/funasr_tool.py b/skills/kb-funasr/funasr_tool.py deleted file mode 100755 index b082155..0000000 --- a/skills/kb-funasr/funasr_tool.py +++ /dev/null @@ -1,313 +0,0 @@ -#!/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() diff --git a/skills/kb-link-parser/SKILL.md b/skills/kb-link-parser/SKILL.md deleted file mode 100644 index b8c97cc..0000000 --- a/skills/kb-link-parser/SKILL.md +++ /dev/null @@ -1,43 +0,0 @@ -# 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/ -``` diff --git a/skills/kb-link-parser/__pycache__/link_parser_shortlink.cpython-312.pyc b/skills/kb-link-parser/__pycache__/link_parser_shortlink.cpython-312.pyc deleted file mode 100644 index eeed19816b30e8fca3e9073134e6c6cc52360ef3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10849 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 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()) diff --git a/skills/kb-link-parser/link_parser_shortlink.py b/skills/kb-link-parser/link_parser_shortlink.py deleted file mode 100644 index 20739e7..0000000 --- a/skills/kb-link-parser/link_parser_shortlink.py +++ /dev/null @@ -1,296 +0,0 @@ -#!/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() diff --git a/skills/kb-ocr-processor/SKILL.md b/skills/kb-ocr-processor/SKILL.md deleted file mode 100644 index cfd9ef5..0000000 --- a/skills/kb-ocr-processor/SKILL.md +++ /dev/null @@ -1,66 +0,0 @@ -# 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) -``` diff --git a/skills/kb-ocr-processor/ocr_processor.py b/skills/kb-ocr-processor/ocr_processor.py deleted file mode 100644 index b58d618..0000000 --- a/skills/kb-ocr-processor/ocr_processor.py +++ /dev/null @@ -1,360 +0,0 @@ -#!/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()) diff --git a/skills/kb-ocr-processor/requirements.txt b/skills/kb-ocr-processor/requirements.txt deleted file mode 100644 index f3f72c2..0000000 --- a/skills/kb-ocr-processor/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -easyocr>=1.7.0 -pytesseract>=0.3.10 -Pillow>=9.0.0 diff --git a/skills/kb-whisper-diarization/.env.example b/skills/kb-whisper-diarization/.env.example deleted file mode 100644 index 108647b..0000000 --- a/skills/kb-whisper-diarization/.env.example +++ /dev/null @@ -1,9 +0,0 @@ -# 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 diff --git a/skills/kb-whisper-diarization/SKILL.md b/skills/kb-whisper-diarization/SKILL.md deleted file mode 100644 index 4f705d7..0000000 --- a/skills/kb-whisper-diarization/SKILL.md +++ /dev/null @@ -1,127 +0,0 @@ -# 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 -- **处理时间较长**: 说话者分离会增加处理时间 -- **对立体声效果更好**: 多声道音频的分离效果更佳 diff --git a/skills/kb-whisper-diarization/requirements.txt b/skills/kb-whisper-diarization/requirements.txt deleted file mode 100644 index d465929..0000000 --- a/skills/kb-whisper-diarization/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -# KB Whisper Diarization 依赖 - -# 核心依赖 -torch>=2.0.0 -pyannote.audio>=1.0.0 - -# 可选: GPU 加速 -# torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 diff --git a/skills/kb-whisper-diarization/whisper_diarization.py b/skills/kb-whisper-diarization/whisper_diarization.py deleted file mode 100644 index 805fb4c..0000000 --- a/skills/kb-whisper-diarization/whisper_diarization.py +++ /dev/null @@ -1,339 +0,0 @@ -#!/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() diff --git a/skills/kb-whisper/.env.example b/skills/kb-whisper/.env.example deleted file mode 100644 index 8ffc440..0000000 --- a/skills/kb-whisper/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -# KB Whisper 环境变量配置示例 -# 复制此文件为 .env 并填写实际值 - -# Whisper.cpp 安装路径 -WHISPER_CPP_PATH=~/whisper.cpp diff --git a/skills/kb-whisper/README.md b/skills/kb-whisper/README.md deleted file mode 100644 index 7b84ea8..0000000 --- a/skills/kb-whisper/README.md +++ /dev/null @@ -1,211 +0,0 @@ -# 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 许可证。 diff --git a/skills/kb-whisper/SKILL.md b/skills/kb-whisper/SKILL.md deleted file mode 100644 index 9ebb999..0000000 --- a/skills/kb-whisper/SKILL.md +++ /dev/null @@ -1,124 +0,0 @@ ---- -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 等常见格式 diff --git a/skills/kb-whisper/__pycache__/whisper.cpython-312.pyc b/skills/kb-whisper/__pycache__/whisper.cpython-312.pyc deleted file mode 100644 index c6051698ed03d328c80177e66b05c9a2bd5f5dd9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13164 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 diff --git a/skills/kb-whisper/examples.md b/skills/kb-whisper/examples.md deleted file mode 100644 index 02905d0..0000000 --- a/skills/kb-whisper/examples.md +++ /dev/null @@ -1,273 +0,0 @@ -# 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" -``` diff --git a/skills/kb-whisper/whisper.py b/skills/kb-whisper/whisper.py deleted file mode 100755 index 4d95f3a..0000000 --- a/skills/kb-whisper/whisper.py +++ /dev/null @@ -1,403 +0,0 @@ -#!/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() diff --git a/skills/kb-workflow/.env b/skills/kb-workflow/.env deleted file mode 100644 index dc8e79b..0000000 --- a/skills/kb-workflow/.env +++ /dev/null @@ -1,14 +0,0 @@ -# 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" diff --git a/skills/kb-workflow/.env.example b/skills/kb-workflow/.env.example deleted file mode 100644 index dc8e79b..0000000 --- a/skills/kb-workflow/.env.example +++ /dev/null @@ -1,14 +0,0 @@ -# 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" diff --git a/skills/kb-workflow/README.md b/skills/kb-workflow/README.md deleted file mode 100644 index 14d720b..0000000 --- a/skills/kb-workflow/README.md +++ /dev/null @@ -1,247 +0,0 @@ -# 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 -``` - -### 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 diff --git a/skills/kb-workflow/SKILL.md b/skills/kb-workflow/SKILL.md deleted file mode 100644 index d763b00..0000000 --- a/skills/kb-workflow/SKILL.md +++ /dev/null @@ -1,360 +0,0 @@ ---- -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" -``` - diff --git a/skills/kb-workflow/SkillBug.md b/skills/kb-workflow/SkillBug.md deleted file mode 100644 index ae5df03..0000000 --- a/skills/kb-workflow/SkillBug.md +++ /dev/null @@ -1,101 +0,0 @@ -# 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:归档(阻塞) diff --git a/skills/kb-workflow/install-env.sh b/skills/kb-workflow/install-env.sh deleted file mode 100755 index 39d0f3e..0000000 --- a/skills/kb-workflow/install-env.sh +++ /dev/null @@ -1,321 +0,0 @@ -#!/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 "" diff --git a/skills/kb-workflow/kb_workflow.py b/skills/kb-workflow/kb_workflow.py deleted file mode 100644 index 5c93277..0000000 --- a/skills/kb-workflow/kb_workflow.py +++ /dev/null @@ -1,337 +0,0 @@ -#!/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() diff --git a/skills/kb-zhihu-metadata/SKILL.md b/skills/kb-zhihu-metadata/SKILL.md deleted file mode 100644 index d4e77d7..0000000 --- a/skills/kb-zhihu-metadata/SKILL.md +++ /dev/null @@ -1,44 +0,0 @@ -# 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/ -``` diff --git a/skills/kb-zhihu-metadata/markdown_converter.py b/skills/kb-zhihu-metadata/markdown_converter.py deleted file mode 100644 index c0f912d..0000000 --- a/skills/kb-zhihu-metadata/markdown_converter.py +++ /dev/null @@ -1,17 +0,0 @@ -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) diff --git a/skills/kb-zhihu-metadata/zhihu_extractor.py b/skills/kb-zhihu-metadata/zhihu_extractor.py deleted file mode 100644 index f917f79..0000000 --- a/skills/kb-zhihu-metadata/zhihu_extractor.py +++ /dev/null @@ -1,335 +0,0 @@ -""" -知乎 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) diff --git a/skills/kb-zhihu-metadata/zhihu_metadata.py b/skills/kb-zhihu-metadata/zhihu_metadata.py deleted file mode 100644 index fcff2b1..0000000 --- a/skills/kb-zhihu-metadata/zhihu_metadata.py +++ /dev/null @@ -1,153 +0,0 @@ -#!/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()