feat: Add KB knowledge base series skills

- kb-link-parser: Multi-platform URL parser (Bilibili, YouTube, Zhihu)
- kb-bilibili-metadata: Bilibili video/article/up主 metadata extractor
- kb-zhihu-metadata: Zhihu question/answer/user metadata extractor
- kb-bilibili-content: Bilibili video download and content extraction
- kb-ocr-processor: OCR text extraction from video frames
- kb-deep-analyzer: AI-powered deep content analysis
- kb-workflow: Workflow orchestration (inbox, archive, subscriptions, report)

Also add:
- ARCHITECTURE_FLOWCHART.md: System architecture diagrams
- DETAILED_DESIGN.md: Complete design documentation
This commit is contained in:
OpenClaw Agent
2026-02-10 12:18:52 +08:00
parent 2f5a269866
commit 5507b1d986
28 changed files with 7715 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
# KB Bilibili Content Skill
**kb-bilibili-content** - Download Bilibili videos and extract content.
## Features
- Video download (multiple quality options)
- Subtitle extraction
- Article content extraction
- Low quality for OCR (480p default)
## Commands
```bash
# Download video
python bilibili_content.py --id "BV1xx411c7mD" --type video --quality 480
# Extract subtitle
python bilibili_content.py --id "BV1xx411c7mD" --type subtitle
# Get article content
python bilibili_content.py --id "cv43780186" --type article
```
## Output
```json
{
"success": true,
"data": {
"platform": "bilibili",
"type": "video",
"id": "BV1xx411c7mD",
"video_file": {"path": "output/BV1xx.mp4", "quality": "480p"},
"subtitle": {"path": "output/BV1xx.srt"}
}
}
```
## Installation
```bash
cp -r kb-bilibili-content/ ~/skills/
pip install aiohttp
```
@@ -0,0 +1,452 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Bilibili Content Downloader - B站内容获取工具
功能:
- download_video(bvid, quality=480) - 下载低分辨率视频
- extract_subtitle(bvid) - 提取字幕
- extract_article(cvid) - 获取专栏全文
- 命令行参数支持
- JSON 输出
"""
import argparse
import asyncio
import json
import os
import re
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional, Dict, Any, List
import aiohttp
class BilibiliContent:
"""B站内容获取器"""
def __init__(self, output_dir: str = "./output"):
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
def _get_headers(self) -> Dict[str, str]:
"""获取请求头"""
return {
'Referer': 'https://www.bilibili.com/',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
}
async def _make_request(self, session, url: str, params: Dict = None) -> Optional[Dict]:
"""发送API请求"""
try:
import aiohttp
async with session.get(url, params=params, headers=self._get_headers(), timeout=aiohttp.ClientTimeout(total=30)) as response:
if response.status == 200:
return await response.json()
return None
except Exception as e:
print(f"请求错误: {e}")
return None
async def download_video(self, bvid: str, quality: int = 480) -> Dict[str, Any]:
"""
下载B站视频
Args:
bvid: B站视频BV号 (如: BV1xx411c7mD)
quality: 视频质量 (默认480)
- 127: 8K
- 126:杜比
- 125:HDR
- 120:4K
- 116:1080P60
- 112:1080P+
- 80:1080P
- 74:720P60
- 64:720P
- 32:480P
- 16:360P
Returns:
Dict: 包含状态、消息和文件路径的结果
"""
result = {
"success": False,
"bvid": bvid,
"message": "",
"filepath": None,
"metadata": {}
}
async with aiohttp.ClientSession() as session:
# 1. 获取视频信息
print(f"正在获取视频信息: {bvid}")
api_url = "https://api.bilibili.com/x/web-interface/view"
data = await self._make_request(session, api_url, {'bvid': bvid})
if not data or data.get('code') != 0:
result["message"] = f"获取视频信息失败: {data.get('message', '未知错误')}"
return result
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', 0)
author = video_data.get('owner', {}).get('name', '未知作者')
result["metadata"] = {
"title": title,
"author": author,
"aid": aid,
"cid": cid,
"duration": duration,
"bvid": bvid
}
print(f"视频标题: {title}")
print(f"作者: {author}")
print(f"时长: {duration}秒")
# 2. 获取视频流地址
print(f"\n正在获取视频流...")
# 选择合适的质量
quality_map = {127: 127, 126: 126, 125: 125, 120: 120, 116: 116, 112: 112, 80: 80, 74: 74, 64: 64, 32: 32, 16: 16}
target_quality = quality_map.get(quality, 32) # 默认480P
playurl_api = "https://api.bilibili.com/x/player/playurl"
params = {
'bvid': bvid,
'cid': cid,
'qn': target_quality,
'otype': 'json',
'fnver': 0,
'fnval': 16, # 支持DASH格式
}
playurl_data = await self._make_request(session, playurl_api, params)
if not playurl_data or playurl_data.get('code') != 0:
result["message"] = f"获取视频流失败: {playurl_data.get('message', '未知错误')}"
return result
play_data = playurl_data.get('data', {})
# 获取视频和音频流
dash_data = play_data.get('dash')
if dash_data:
video_list = dash_data.get('video', [])
audio_list = dash_data.get('audio', [])
if video_list:
# 选择质量最高的视频流
video_stream = video_list[0]
video_url = video_stream.get('base_url') or video_stream.get('baseUrl')
video_size = video_list[0].get('size', 0)
# 获取音频流
audio_stream = audio_list[0] if audio_list else None
audio_url = audio_stream.get('base_url') or audio_stream.get('baseUrl') if audio_stream else None
print(f"找到视频流: {video_url[:100]}...")
# 3. 下载视频流
print(f"\n正在下载视频...")
# 下载视频
async with session.get(video_url, headers=self._get_headers()) as resp:
if resp.status == 200:
video_content = await resp.read()
print(f"视频下载完成: {len(video_content) / 1024 / 1024:.2f}MB")
else:
result["message"] = f"视频下载失败: HTTP {resp.status}"
return result
# 下载音频
audio_content = None
if audio_url:
print(f"正在下载音频...")
async with session.get(audio_url, headers=self._get_headers()) as resp:
if resp.status == 200:
audio_content = await resp.read()
print(f"音频下载完成: {len(audio_content) / 1024 / 1024:.2f}MB")
# 4. 合并音视频
print(f"\n正在合并音视频...")
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).strip()[:50]
safe_title = re.sub(r'[<>:"/\\|?*]', '', safe_title)
output_path = self.output_dir / f"{safe_title}_{bvid}"
# 保存文件
if video_content:
video_path = output_path.with_suffix('.mp4')
with open(video_path, 'wb') as f:
f.write(video_content)
result["filepath"] = str(video_path)
result["success"] = True
result["message"] = f"视频已保存: {video_path}"
print(f"✅ 下载完成: {video_path}")
else:
result["message"] = "未找到视频流"
else:
# 回退到FLV格式
durl = play_data.get('durl', [])
if durl:
video_url = durl[0].get('url')
print(f"使用FLV格式: {video_url[:100]}...")
async with session.get(video_url, headers=self._get_headers()) as resp:
if resp.status == 200:
video_content = await resp.read()
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).strip()[:50]
safe_title = re.sub(r'[<>:"/\\|?*]', '', safe_title)
output_path = self.output_dir / f"{safe_title}_{bvid}.flv"
with open(output_path, 'wb') as f:
f.write(video_content)
result["filepath"] = str(output_path)
result["success"] = True
result["message"] = f"视频已保存: {output_path}"
print(f"✅ 下载完成: {output_path}")
else:
result["message"] = f"视频下载失败: HTTP {resp.status}"
else:
result["message"] = "未找到可用的视频流"
return result
async def extract_subtitle(self, bvid: str) -> Dict[str, Any]:
"""
提取B站视频字幕
Args:
bvid: B站视频BV号
Returns:
Dict: 包含字幕内容的JSON结果
"""
result = {
"success": False,
"bvid": bvid,
"subtitles": [],
"message": ""
}
async with aiohttp.ClientSession() as session:
# 获取视频信息
api_url = "https://api.bilibili.com/x/web-interface/view"
data = await self._make_request(session, api_url, {'bvid': bvid})
if not data or data.get('code') != 0:
result["message"] = f"获取视频信息失败: {data.get('message', '未知错误')}"
return result
cid = data.get('data', {}).get('cid')
# 获取字幕列表
subtitle_api = "https://api.bilibili.com/x/player/pagelist"
page_data = await self._make_request(session, subtitle_api, {'bvid': bvid})
# 获取字幕详情
subtitle_list = []
if page_data and page_data.get('code') == 0:
cid = page_data.get('data', [{}])[0].get('cid', cid)
subtitle_url = f"https://api.bilibili.com/x/player/v2?bvid={bvid}&cid={cid}"
subtitle_data = await self._make_request(session, subtitle_url)
if subtitle_data and subtitle_data.get('code') == 0:
subtitles = subtitle_data.get('data', {}).get('subtitle', {}).get('list', [])
for sub in subtitles:
sub_title = sub.get('lan_doc', '未知语言')
sub_url = sub.get('subtitle_url')
if sub_url:
# 下载字幕文件
async with session.get(sub_url) as resp:
if resp.status == 200:
sub_content = await resp.json()
subtitle_list.append({
"language": sub.get('lan', 'unknown'),
"title": sub_title,
"content": sub_content
})
result["subtitles"] = subtitle_list
result["success"] = len(subtitle_list) > 0
result["message"] = f"找到 {len(subtitle_list)} 个字幕" if subtitle_list else "未找到字幕"
# 保存字幕到文件
if subtitle_list:
output_path = self.output_dir / f"{bvid}_subtitles.json"
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
result["filepath"] = str(output_path)
print(f"✅ 字幕已保存: {output_path}")
return result
async def extract_article(self, cvid: str) -> Dict[str, Any]:
"""
获取B站专栏全文
Args:
cvid: B站专栏CV号
Returns:
Dict: 包含专栏内容的JSON结果
"""
result = {
"success": False,
"cvid": cvid,
"title": "",
"content": "",
"message": ""
}
async with aiohttp.ClientSession() as session:
# 获取专栏信息
api_url = "https://api.bilibili.com/x/article/viewinfo"
data = await self._make_request(session, api_url, {'id': cvid})
if not data or data.get('code') != 0:
result["message"] = f"获取专栏信息失败: {data.get('message', '未知错误')}"
return result
article_data = data.get('data', {})
title = article_data.get('title', '')
content = article_data.get('content', '')
# 获取作者信息
author = article_data.get('author', {}).get('name', '未知作者')
publish_time = article_data.get('publish_time', 0)
view = article_data.get('stats', {}).get('view', 0)
like = article_data.get('stats', {}).get('like', 0)
result["title"] = title
result["content"] = content
result["author"] = author
result["publish_time"] = datetime.fromtimestamp(publish_time).strftime('%Y-%m-%d %H:%M:%S') if publish_time else ''
result["stats"] = {"view": view, "like": like}
result["success"] = bool(title and content)
result["message"] = f"成功获取专栏: {title}" if result["success"] else "获取专栏内容失败"
# 保存到文件
if result["success"]:
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).strip()[:30]
safe_title = re.sub(r'[<>:"/\\|?*]', '', safe_title)
# 保存JSON
json_path = self.output_dir / f"{safe_title}_{cvid}.json"
with open(json_path, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
# 保存Markdown
md_content = f"""# {title}
> **来源**: [B站专栏](https://www.bilibili.com/read/cv{cvid})
> **作者**: {author}
> **发布时间**: {result["publish_time"]}
> **阅读**: {view} | **点赞**: {like}
---
{content}
"""
md_path = self.output_dir / f"{safe_title}_{cvid}.md"
with open(md_path, 'w', encoding='utf-8') as f:
f.write(md_content)
result["json_path"] = str(json_path)
result["md_path"] = str(md_path)
print(f"✅ 专栏已保存: {md_path}")
return result
async def main():
"""命令行入口"""
parser = argparse.ArgumentParser(
description='Bilibili Content Downloader - B站内容获取工具',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
%(prog)s --id "BV1xx411c7mD" --type video # 下载视频
%(prog)s --id "BV1xx411c7mD" --type video --quality 720 # 下载720P视频
%(prog)s --id "BV1xx411c7mD" --type subtitle # 提取字幕
%(prog)s --id "cv43780186" --type article # 获取专栏
"""
)
parser.add_argument('--id', '-i', required=True, help='B站内容ID (BV号或CV号)')
parser.add_argument('--type', '-t', required=True, choices=['video', 'subtitle', 'article'],
help='内容类型: video=视频, subtitle=字幕, article=专栏')
parser.add_argument('--output', '-o', default='./output', help='输出目录 (默认: ./output)')
parser.add_argument('--quality', '-q', type=int, default=480,
help='视频质量 (默认: 480, 可选: 360, 480, 720, 1080)')
parser.add_argument('--json', action='store_true', help='JSON格式输出')
args = parser.parse_args()
# 确定内容类型
is_article = args.id.startswith('cv')
if args.type == 'article' or is_article:
content_type = 'article'
content_id = args.id.replace('cv', '')
elif args.type == 'subtitle':
content_type = 'subtitle'
content_id = args.id
else:
content_type = 'video'
content_id = args.id
# 创建获取器
downloader = BilibiliContent(output_dir=args.output)
# 执行操作
if content_type == 'video':
result = await downloader.download_video(content_id, quality=args.quality)
elif content_type == 'subtitle':
result = await downloader.extract_subtitle(content_id)
else:
result = await downloader.extract_article(content_id)
# 输出结果
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
if result["success"]:
print(f"\n✅ {result['message']}")
else:
print(f"\n❌ {result['message']}")
if result.get("filepath"):
print(f"文件路径: {result['filepath']}")
return result
if __name__ == '__main__':
try:
result = asyncio.run(main())
sys.exit(0 if result.get("success") else 1)
except KeyboardInterrupt:
print("\n操作已取消")
sys.exit(1)
except Exception as e:
print(f"\n错误: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
B站视频提取脚本
提取视频信息、统计数据、字幕并生成Markdown笔记
"""
import sys
import os
import json
import re
from datetime import datetime
# 添加skills路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'skills', 'obsidian-kb'))
from src.extractors.bilibili_extractor import BilibiliExtractor
from src.config import get_config
def format_number(num):
"""格式化数字"""
if num >= 100000000:
return f"{num / 100000000:.1f}亿"
elif num >= 10000:
return f"{num / 10000:.1f}万"
else:
return f"{num:,}"
def generate_markdown(result, bvid):
"""生成Markdown笔记"""
metadata = result['metadata']
stats = result['stats']
description = result['description']
subtitle = result['subtitle']
# 获取当前时间
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
md = f"""# {metadata['title']}
> **来源**: [B站](https://www.bilibili.com/video/{bvid})
> **作者**: {metadata['author']}
> **发布时间**: {metadata['pubtime']}
> **提取时间**: {now}
> **时长**: {metadata['duration']}
## 📊 统计数据
| 指标 | 数量 |
|------|------|
| 👀 播放 | {format_number(stats['view'])} |
| 💬 弹幕 | {format_number(stats['danmaku'])} |
| 📝 评论 | {format_number(stats['reply'])} |
| ⭐ 收藏 | {format_number(stats['favorite'])} |
| 🪙 投币 | {format_number(stats['coin'])} |
| 🔗 分享 | {format_number(stats['share'])} |
| 👍 点赞 | {format_number(stats['like'])} |
## 📝 视频简介
{description if description else '(暂无简介)'}
## 📌 标签
{metadata['tags'] if metadata['tags'] else '(无标签)'}
## 🎬 字幕
{subtitle if subtitle else '(无字幕)'}
---
**元数据**:
- BV号: `{metadata['bvid']}`
- AV号: `{metadata['aid']}`
- UP主ID: `{metadata['mid']}`
"""
return md
def main():
"""主函数"""
# BV号
bvid = "BV1N5foBjE5K" # 从 https://b23.tv/btMGoOi 解析得到
print(f"开始提取B站视频: {bvid}")
print("=" * 60)
# 获取配置
try:
config = get_config()
except Exception as e:
print(f"⚠️ 获取配置失败,使用默认配置: {e}")
config = None
# 创建提取器
extractor = BilibiliExtractor(config=config)
# 提取视频信息
try:
result = extractor.extract(bvid)
print("\n✅ 提取成功!\n")
print("=" * 60)
print("基本信息:")
print("=" * 60)
print(f"标题: {result['metadata']['title']}")
print(f"作者: {result['metadata']['author']}")
print(f"时长: {result['metadata']['duration']}")
print(f"发布: {result['metadata']['pubtime']}")
print("\n" + "=" * 60)
print("统计数据:")
print("=" * 60)
print(f"播放: {format_number(result['stats']['view'])}")
print(f"弹幕: {format_number(result['stats']['danmaku'])}")
print(f"评论: {format_number(result['stats']['reply'])}")
print(f"收藏: {format_number(result['stats']['favorite'])}")
print(f"投币: {format_number(result['stats']['coin'])}")
print(f"点赞: {format_number(result['stats']['like'])}")
# 生成 Markdown 笔记
md_content = generate_markdown(result, bvid)
# 确保 notes 目录存在
notes_dir = "/home/admin/.openclaw/workspace/notes"
os.makedirs(notes_dir, exist_ok=True)
# 生成文件名(使用BV号作为文件名)
filename = f"{bvid}.md"
filepath = os.path.join(notes_dir, filename)
# 写入文件
with open(filepath, 'w', encoding='utf-8') as f:
f.write(md_content)
print("\n" + "=" * 60)
print(f"✅ Markdown笔记已生成: {filepath}")
print("=" * 60)
# 返回笔记内容
return md_content
except Exception as e:
print(f"\n❌ 提取失败: {e}")
import traceback
traceback.print_exc()
return None
if __name__ == '__main__':
main()
@@ -0,0 +1,175 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
使用旧版 API 获取并下载视频流
"""
import asyncio
import json
import os
from playwright.async_api import async_playwright
async def download_video_stream(bvid, output_dir="/tmp/bilibili_downloads"):
"""下载视频流"""
url = f"https://www.bilibili.com/video/{bvid}"
# 创建输出目录
os.makedirs(output_dir, exist_ok=True)
async with async_playwright() as playwright:
browser = await playwright.chromium.launch(headless=True)
context = await browser.new_context()
page = await context.new_page()
print(f"正在下载视频: {url}")
print("=" * 80)
# 首先获取 aid 和 cid
print("\n1. 获取视频信息...")
try:
api_url = "https://api.bilibili.com/x/web-interface/view"
response = await page.request.get(api_url, params={'bvid': bvid})
data = await response.json()
if data.get('code') != 0:
print(f" ❌ 失败: {data.get('message')}")
await browser.close()
return None
video_data = data.get('data', {})
title = video_data.get('title')
aid = video_data.get('aid')
cid = video_data.get('cid')
duration = video_data.get('duration')
print(f" 标题: {title}")
print(f" 时长: {duration}秒")
except Exception as e:
print(f" ❌ 异常: {e}")
await browser.close()
return None
# 获取视频流 URL
print("\n2. 获取视频流 URL...")
try:
playurl_api = "https://api.bilibili.com/x/player/playurl"
params = {
'bvid': bvid,
'cid': cid,
'qn': 80, # 1080P
'otype': 'json',
'fnver': 0,
'fnval': 0, # FLV 格式
}
playurl_response = await page.request.get(playurl_api, params=params)
if not playurl_response.ok:
print(f" ❌ HTTP 错误: {playurl_response.status}")
await browser.close()
return None
playurl_data = await playurl_response.json()
if playurl_data.get('code') != 0:
print(f" ❌ API 错误: {playurl_data.get('message')}")
await browser.close()
return None
play_data = playurl_data.get('data', {})
# 检查支持的清晰度
print(f"\n 支持的清晰度:")
for i, (q, desc) in enumerate(zip(play_data.get('accept_quality', []), play_data.get('accept_description', []))):
current = " (当前)" if q == play_data.get('quality') else ""
print(f" {desc} ({q}){current}")
# 获取视频 URL
durl = play_data.get('durl', [])
if not durl:
print(f" ❌ 没有找到视频流")
await browser.close()
return None
print(f"\n 找到 {len(durl)} 个视频分段")
video_urls = []
for i, segment in enumerate(durl):
segment_url = segment.get('url')
segment_size = segment.get('size', 0)
video_urls.append(segment_url)
print(f" 分段 {i+1}: {segment_size / 1024 / 1024:.2f}MB")
print(f" {segment_url[:100]}...")
except Exception as e:
print(f" ❌ 异常: {e}")
import traceback
traceback.print_exc()
await browser.close()
return None
# 下载视频
print(f"\n3. 开始下载视频...")
all_segments = []
total_size = 0
for i, video_url in enumerate(video_urls, 1):
print(f"\n 下载分段 {i}/{len(video_urls)}...")
try:
# 添加必要的请求头
headers = {
'Referer': 'https://www.bilibili.com/',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
}
# 下载分段
segment_response = await page.request.get(
video_url,
headers=headers,
timeout=60000
)
if not segment_response.ok:
print(f" ❌ 下载失败: {segment_response.status}")
continue
segment_data = await segment_response.body()
segment_size = len(segment_data)
total_size += segment_size
all_segments.append(segment_data)
print(f" ✅ 下载成功: {segment_size / 1024 / 1024:.2f}MB")
except Exception as e:
print(f" ❌ 下载异常: {e}")
continue
if not all_segments:
print(f"\n ❌ 没有成功下载任何分段")
await browser.close()
return None
# 合并分段
print(f"\n4. 合并视频分段...")
video_data = b''.join(all_segments)
# 保存文件
safe_title = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).strip()[:50]
output_path = os.path.join(output_dir, f"{safe_title}.flv")
with open(output_path, 'wb') as f:
f.write(video_data)
print(f" ✅ 视频已保存: {output_path}")
print(f" 文件大小: {total_size / 1024 / 1024:.2f}MB")
await browser.close()
return output_path
if __name__ == '__main__':
bvid = "BV1KtFzzqEUG"
result = asyncio.run(download_video_stream(bvid))
if result:
print(f"\n✅ 下载完成!")
print(f"文件路径: {result}")
else:
print(f"\n❌ 下载失败")
+46
View File
@@ -0,0 +1,46 @@
# KB Bilibili Metadata Skill
**kb-bilibili-metadata** - Extract Bilibili video/article/up主 metadata.
## Features
- Video metadata extraction
- Article (专栏) metadata extraction
- UP主 information extraction
- JSON output format
- Playwright + HTTP API fallback
## Commands
```bash
# Video metadata
python bilibili_metadata.py --id "BV1xx411c7mD" --type video
# Article metadata
python bilibili_metadata.py --id "cv43780186" --type article
# UP主 info
python bilibili_metadata.py --id "7770724" --type up
```
## Output
```json
{
"success": true,
"data": {
"platform": "bilibili",
"type": "video",
"id": "BV1xx411c7mD",
"title": "视频标题",
"author": "UP主名字",
"stats": {"view": 100000, "like": 5000}
}
}
```
## Installation
```bash
cp -r kb-bilibili-metadata/ ~/skills/
```
+8
View File
@@ -0,0 +1,8 @@
"""
bilibili-metadata 技能包
B站元数据提取工具
"""
__version__ = "1.0.0"
__author__ = "OpenClaw"
@@ -0,0 +1,446 @@
#!/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主信息"""
result = {}
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 = {
'mid': mid,
'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['video_count'] = stats_data.get('data', {}).get('video', 0)
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}"
# 测试代码
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))
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
B站元数据提取 CLI 工具
支持命令行调用,输出 JSON 格式数据
"""
import argparse
import json
import sys
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 = extractor.extract_up_metadata(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 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 "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'],
help='资源类型'
)
parser.add_argument(
'--output',
'-o',
help='输出文件路径(JSON格式)'
)
parser.add_argument(
'--headless',
action='store_true',
default=True,
help='使用无头模式(浏览器,默认开启)'
)
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)
else:
print(f"错误: 不支持的类型 {args.type}")
sys.exit(1)
# 输出结果
if not args.output:
if args.pretty:
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print(json.dumps(result, ensure_ascii=False))
if args.output:
print(f"结果已保存到: {args.output}")
except Exception as e:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()
+48
View File
@@ -0,0 +1,48 @@
# 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
## Commands
```bash
# Analyze content
python deep_analyzer.py --metadata meta.json --content content.json --output analysis.md
# Specify type
python deep_analyzer.py --metadata meta.json --content content.json --type tutorial --output tutorial.md
```
## Output
```markdown
---
title: "分析标题"
source: "bilibili"
quality_score: 95
tags:
- python
- tutorial
---
## 一句话总结
内容摘要...
## 核心要点
1. 要点1
2. 要点2
```
## Installation
```bash
cp -r kb-deep-analyzer/ ~/skills/
```
+514
View File
@@ -0,0 +1,514 @@
"""Obsidian KB - AI客户端
支持多个 AI 提供商和 fallback 机制
"""
import os
import json
import requests
from typing import Dict, Any, List, Optional, Tuple
from src.models import ContentType
from src.config import get_config
from src.content_chunker import ContentChunker, get_chunking_strategy, chunk_content
class AIClient:
"""
AI客户端 - 支持分段分析长视频内容和 fallback 机制
"""
def __init__(self):
self.config = get_config()
self.chunker = ContentChunker()
# 获取 AI 配置
self._load_ai_config()
def _load_ai_config(self) -> None:
"""加载 AI 配置(主 provider + fallbacks)"""
self.ai_configs = []
# 加载主配置
main_provider = self.config.get('ai.provider', 'openai')
main_config = {
'provider': main_provider,
'api_key': self.config.get('ai.api_key'),
'model': self.config.get('ai.model', 'gpt-4'),
'base_url': self.config.get('ai.base_url'),
'is_primary': True
}
self.ai_configs.append(main_config)
# 加载 fallback 配置
fallbacks = self.config.get('ai.fallbacks', [])
for fb in fallbacks:
self.ai_configs.append({
'provider': fb.get('provider'),
'api_key': fb.get('api_key'),
'model': fb.get('model'),
'base_url': fb.get('base_url'),
'is_primary': False
})
def _call_api_with_fallback(self, prompt: str, max_tokens: int = 1000, response_format: str = 'text') -> Tuple[bool, str]:
"""
使用 fallback 机制调用 API
Args:
prompt: 提示词
max_tokens: 最大 token 数
response_format: 响应格式 ('text' 或 'json')
Returns:
(success, response) 元组
"""
last_error = None
for i, ai_config in enumerate(self.ai_configs):
try:
if not ai_config.get('api_key'):
print(f"[AIClient] 跳过 {ai_config.get('provider')} - API Key 为空")
continue
provider_type = ai_config.get('provider')
print(f"[AIClient] 尝试使用 {provider_type} (优先级 {i+1}/{len(self.ai_configs)})...")
response = self._call_single_provider(ai_config, prompt, max_tokens, response_format)
return True, response
except Exception as e:
last_error = e
print(f"[AIClient] {provider_type} 调用失败: {e}")
continue
# 所有 provider 都失败了
error_msg = f"所有 AI 提供商均失败,最后错误: {last_error}" if last_error else "没有可用的 AI 配置"
print(f"[AIClient] {error_msg}")
return False, self._mock_generate(prompt, max_tokens) # 返回 mock 响应作为兜底
def _call_single_provider(self, ai_config: Dict, prompt: str, max_tokens: int, response_format: str) -> str:
"""
调用单个 AI 提供商
Args:
ai_config: AI 配置
prompt: 提示词
max_tokens: 最大 token 数
response_format: 响应格式
Returns:
API 响应文本
"""
provider = ai_config.get('provider')
api_key = ai_config.get('api_key')
model = ai_config.get('model')
base_url = ai_config.get('base_url')
if provider == 'minimax':
return self._call_minimax(api_key, model, base_url, prompt, max_tokens, response_format)
elif provider == 'zhipu':
return self._call_zhipu(api_key, model, base_url, prompt, max_tokens, response_format)
elif provider == 'alibaba':
return self._call_alibaba(api_key, model, base_url, prompt, max_tokens, response_format)
elif provider == 'openai':
return self._call_openai(api_key, model, base_url, prompt, max_tokens, response_format)
else:
raise ValueError(f"不支持的 provider: {provider}")
def _call_minimax(self, api_key: str, model: str, base_url: str, prompt: str, max_tokens: int, response_format: str) -> str:
"""调用 MiniMax API (Anthropic Messages 兼容接口)"""
url = base_url or "https://api.minimaxi.com/anthropic/v1/messages"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
payload = {
"model": model,
"max_tokens": max_tokens,
"messages": [
{"role": "user", "content": prompt}
]
}
if response_format == 'json':
payload['messages'][0]['content'] = prompt + "\n\n请直接返回 JSON 格式,不要有其他文字。"
response = requests.post(url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
data = response.json()
# MiniMax 返回的是 Anthropic 格式,content 是一个数组
# 需要找到 type='text' 的元素
for item in data.get('content', []):
if item.get('type') == 'text':
return item.get('text', '')
# 如果没有找到 text 类型,返回第一个元素的值(兼容旧格式)
return data.get('content', [{}])[0].get('text', '')
def _call_zhipu(self, api_key: str, model: str, base_url: str, prompt: str, max_tokens: int, response_format: str) -> str:
"""调用智谱 AI API (OpenAI 兼容接口)"""
# 确保 base_url 包含 /chat/completions
if base_url and not base_url.endswith('/chat/completions'):
url = f"{base_url}/chat/completions"
else:
url = base_url or "https://open.bigmodel.cn/api/paas/v4/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
payload = {
"model": model,
"max_tokens": max_tokens,
"messages": [
{"role": "user", "content": prompt}
]
}
response = requests.post(url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
data = response.json()
return data['choices'][0]['message']['content']
def _call_alibaba(self, api_key: str, model: str, base_url: str, prompt: str, max_tokens: int, response_format: str) -> str:
"""调用阿里云通义千问 API (OpenAI 兼容接口)"""
url = f"{base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
payload = {
"model": model,
"max_tokens": max_tokens,
"messages": [
{"role": "user", "content": prompt}
]
}
response = requests.post(url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
data = response.json()
return data['choices'][0]['message']['content']
def _call_openai(self, api_key: str, model: str, base_url: str, prompt: str, max_tokens: int, response_format: str) -> str:
"""调用 OpenAI API"""
url = base_url or "https://api.openai.com/v1/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
payload = {
"model": model,
"max_tokens": max_tokens,
"messages": [
{"role": "user", "content": prompt}
]
}
response = requests.post(url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
data = response.json()
return data['choices'][0]['message']['content']
def quick_generate(self, prompt: str, max_tokens: int = 100) -> str:
"""快速生成(用于轻量扫描)"""
success, response = self._call_api_with_fallback(prompt, max_tokens, 'text')
return response
def generate_json(self, prompt: str) -> Dict[str, Any]:
"""生成JSON格式响应"""
success, response = self._call_api_with_fallback(prompt, 1000, 'json')
try:
return json.loads(response)
except:
return self._parse_json_from_text(response)
def _deep_analyze(self, content_text: str, content_type: str, duration: Optional[int] = None) -> Dict[str, Any]:
"""
C5.3: 深度分析入口,根据内容时长决定是否分段分析
Args:
content_text: 内容文本
content_type: 内容类型
duration: 视频时长(秒)
Returns:
分析结果字典
"""
duration = duration or 0
print(f"[AIClient] 深度分析: type={content_type}, duration={duration}s")
# 长视频(>=30分钟)且是视频类型,使用分段分析
if duration >= 1800 and content_type == "video":
print(f"[AIClient] 使用分段分析策略")
return self._analyze_in_chunks(content_text, duration)
# 短视频直接分析
print(f"[AIClient] 使用直接分析策略")
return self._analyze_direct(content_text)
def _analyze_in_chunks(self, content_text: str, duration: int) -> Dict[str, Any]:
"""
C5.3: 分段分析长视频
Args:
content_text: SRT字幕文本
duration: 视频时长(秒)
Returns:
合并后的分析结果
"""
# 1. 获取分段策略
strategy = get_chunking_strategy(duration)
print(f"[AIClient] 分段策略: {strategy['description']}")
# 2. 执行分片
chunks = chunk_content(content_text, duration)
print(f"[AIClient] 内容已分为 {len(chunks)} 段")
# 3. 逐段分析
chunk_summaries = []
for i, chunk in enumerate(chunks):
print(f"[AIClient] 分析第 {i+1}/{len(chunks)} 段...")
summary = self._analyze_chunk(chunk.text, chunk.index)
# 处理时间格式(可能是float秒数或字符串)
start_time = chunk.start_time
end_time = chunk.end_time
if isinstance(start_time, (int, float)):
start_time = self._format_time(start_time)
if isinstance(end_time, (int, float)):
end_time = self._format_time(end_time)
chunk_summaries.append({
'index': chunk.index,
'start_time': start_time,
'end_time': end_time,
'summary': summary
})
# 4. 合并所有段摘要
print(f"[AIClient] 合并 {len(chunk_summaries)} 段分析结果")
final_result = self._merge_summaries(chunk_summaries, "video")
# 5. 构建完整时间轴
final_result['timeline'] = self._build_timeline_from_chunks(chunk_summaries)
return final_result
def _analyze_chunk(self, chunk_text: str, chunk_index: int) -> str:
"""
C5.3: 分析单个内容分片
Args:
chunk_text: 分片文本
chunk_index: 分片索引
Returns:
分片摘要(限制500字以内)
"""
# 截断文本避免过长
text_sample = chunk_text[:3000] if len(chunk_text) > 3000 else chunk_text
prompt = f"""请对以下视频片段进行简要分析(限制在300字以内):
片段内容:
{text_sample}
请提供:
1. 这段的核心主题(50字以内)
2. 关键要点(3-5条)
3. 重要细节或引用
请用中文回答,控制在300字以内。"""
try:
summary = self.quick_generate(prompt, max_tokens=500)
# 确保不超过500字
if len(summary) > 500:
summary = summary[:497] + "..."
return summary
except Exception as e:
print(f"[AIClient] 分片 {chunk_index} 分析失败: {e}")
return f"第{chunk_index+1}段分析失败"
def _merge_summaries(self, chunk_summaries: List[Dict], content_type: str) -> Dict[str, Any]:
"""
C5.3: 合并所有段摘要,生成完整分析报告
Args:
chunk_summaries: 各段摘要列表
content_type: 内容类型
Returns:
完整分析结果
"""
# 构建合并提示
summaries_text = "\n\n".join([
f"=== 第{s['index']+1}段 ({s['start_time']}-{s['end_time']}) ===\n{s['summary']}"
for s in chunk_summaries
])
prompt = f"""基于以下各段分析,生成完整的视频分析报告:
{summaries_text}
请提供以下格式的JSON:
{{
"summary": "整体摘要(200字以内)",
"topics": ["主题1", "主题2", "主题3", "主题4"],
"insights": ["深入见解1", "深入见解2", "深入见解3"],
"actions": ["建议行动1", "建议行动2"],
"full_analysis": "详细分析(500字以内)"
}}"""
try:
result = self.generate_json(prompt)
# 确保必要字段存在
if 'topics' not in result:
result['topics'] = ["主题A", "主题B", "主题C"]
if 'insights' not in result:
result['insights'] = ["重要见解1", "重要见解2"]
if 'actions' not in result:
result['actions'] = []
if 'summary' not in result:
result['summary'] = "长视频分析完成"
if 'full_analysis' not in result:
result['full_analysis'] = summaries_text[:500]
return result
except Exception as e:
print(f"[AIClient] 合并摘要失败: {e}")
# 返回基础合并结果
return {
'summary': f'视频共分为{len(chunk_summaries)}个部分进行分析',
'topics': [f"主题{i+1}" for i in range(min(4, len(chunk_summaries)))],
'insights': ['从分段分析中综合得出'],
'actions': [],
'full_analysis': summaries_text[:800]
}
def _analyze_direct(self, content_text: str) -> Dict[str, Any]:
"""直接分析(不分段)"""
# 截断文本
text_sample = content_text[:5000] if len(content_text) > 5000 else content_text
prompt = f"""请分析以下内容,提供JSON格式结果:
{text_sample}
返回格式:
{{
"summary": "整体摘要(150字以内)",
"topics": ["主题1", "主题2", "主题3"],
"insights": ["见解1", "见解2"],
"actions": ["行动建议1"],
"timeline": [
{{"start_time": "00:00", "end_time": "05:00", "title": "第一部分", "summary": "..."}}
]
}}"""
try:
return self.generate_json(prompt)
except Exception as e:
print(f"[AIClient] 直接分析失败: {e}")
return {
'summary': '分析失败',
'topics': ['无法解析'],
'insights': ['请检查内容格式'],
'actions': [],
'timeline': []
}
def _format_time(self, seconds: float) -> str:
"""格式化秒数为时间字符串"""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
if hours > 0:
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
return f"{minutes:02d}:{secs:02d}"
def _build_timeline_from_chunks(self, chunk_summaries: List[Dict]) -> List[Dict[str, Any]]:
"""从分片摘要构建时间轴"""
timeline = []
for summary in chunk_summaries:
timeline.append({
'index': summary['index'],
'start_time': summary['start_time'],
'end_time': summary['end_time'],
'title': f"第{summary['index']+1}部分",
'summary': summary['summary'][:200] + "..." if len(summary['summary']) > 200 else summary['summary']
})
return timeline
def _mock_generate(self, prompt: str, max_tokens: int) -> str:
"""模拟AI生成(实际实现应调用真实API)"""
# 根据prompt内容返回模拟响应
prompt_lower = prompt.lower()
if 'json' in prompt_lower or 'json格式' in prompt:
if '主题' in prompt or 'topic' in prompt_lower:
return json.dumps({
'summary': '这是一个关于人工智能和技术发展的内容,涵盖了多个重要主题。',
'topics': ['人工智能', '机器学习', '未来发展', '技术趋势'],
'insights': [
'AI正在改变我们的工作方式',
'机器学习应用越来越广泛',
'未来技术发展趋势值得关注'
],
'actions': ['深入研究相关技术', '关注行业动态'],
'full_analysis': '详细分析内容...'
}, ensure_ascii=False)
return json.dumps({'result': '模拟JSON响应'})
if '摘要' in prompt or 'summary' in prompt_lower:
return '这是一段关于人工智能发展的重要讨论,涵盖了技术趋势和未来应用。'
if '关键词' in prompt or 'keyword' in prompt_lower:
return '人工智能, 机器学习, 深度学习, 神经网络, 自然语言处理'
return '模拟AI响应内容'
def _parse_json_from_text(self, text: str) -> Dict[str, Any]:
"""从文本中解析JSON"""
# 尝试提取JSON块
import re
json_match = re.search(r'\{[\s\S]*\}', text)
if json_match:
try:
return json.loads(json_match.group())
except:
pass
return {'raw_response': text}
class MultiAIClient:
"""
多AI客户端 - 支持多种AI提供商的客户端
"""
def __init__(self):
self.config = get_config()
self.primary_client = AIClient()
self.clients = {
'openai': self.primary_client,
'local': AIClient(), # 可以配置为本地模型
}
def _deep_analyze(self, content_text: str, content_type: str, duration: Optional[int] = None) -> Dict[str, Any]:
"""使用主客户端进行深度分析"""
return self.primary_client._deep_analyze(content_text, content_type, duration)
def quick_generate(self, prompt: str, max_tokens: int = 100, provider: str = None) -> str:
"""快速生成"""
client = self.clients.get(provider, self.primary_client)
return client.quick_generate(prompt, max_tokens)
+771
View File
@@ -0,0 +1,771 @@
#!/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 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 客户端 ==============
class SimpleAIClient:
"""简化的 AI 客户端 - 支持多提供商 fallback"""
# 时长阈值(秒)
SHORT_VIDEO_THRESHOLD = 600 # 10分钟
LONG_VIDEO_THRESHOLD = 1800 # 30分钟
def __init__(self, api_key: str = None, provider: str = "mock", model: str = "gpt-4"):
self.api_key = api_key or os.environ.get("AI_API_KEY", "")
self.provider = provider
self.model = model
self.base_url = os.environ.get("AI_BASE_URL", "")
def generate(self, prompt: str, max_tokens: int = 1000) -> str:
"""生成文本"""
if not self.api_key or self.provider == "mock":
return self._mock_generate(prompt, max_tokens)
try:
if self.provider == "openai":
return self._call_openai(prompt, max_tokens)
elif self.provider == "zhipu":
return self._call_zhipu(prompt, max_tokens)
elif self.provider == "minimax":
return self._call_minimax(prompt, max_tokens)
else:
return self._mock_generate(prompt, max_tokens)
except Exception as e:
print(f"[AI Client] 调用失败: {e}")
return self._mock_generate(prompt, max_tokens)
def generate_json(self, prompt: str) -> Dict[str, Any]:
"""生成 JSON 格式响应"""
response = self.generate(prompt, max_tokens=2000)
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) -> 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=30)
response.raise_for_status()
return response.json()['choices'][0]['message']['content']
def _call_zhipu(self, prompt: str, max_tokens: int) -> str:
"""调用智谱 AI API"""
import requests
base_url = self.base_url or "https://open.bigmodel.cn/api/paas/v4"
url = f"{base_url}/chat/completions"
headers = {"Authorization": f"Bearer {self.api_key}"}
payload = {
"model": self.model,
"messages": [{"role": "user", "content": prompt}]
}
response = requests.post(url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
return response.json()['choices'][0]['message']['content']
def _call_minimax(self, prompt: str, max_tokens: int) -> 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=30)
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)
def _mock_generate(self, prompt: str, max_tokens: int) -> str:
"""模拟 AI 生成"""
prompt_lower = prompt.lower()
if 'json' in prompt_lower or '请返回' in prompt:
# 返回模拟 JSON 响应
if '主题' in prompt or 'topic' in prompt_lower:
return json.dumps({
'summary': '这是一个模拟的AI分析结果,包含内容的主要主题和关键见解。',
'topics': ['主题一', '主题二', '主题三', '主题四'],
'insights': [
'AI正在深刻改变内容消费方式',
'个性化推荐提升了用户体验',
'技术发展带来新的可能性'
],
'actions': ['深入研究相关技术', '关注行业动态'],
'full_analysis': '详细分析内容基于模拟AI生成。'
}, ensure_ascii=False)
if '标签' in prompt or 'tag' in prompt_lower:
return json.dumps({'tags': ['技术', 'AI', '视频分析', '深度学习']}, ensure_ascii=False)
return json.dumps({'result': '模拟响应'}, ensure_ascii=False)
if '摘要' in prompt or 'summary' in prompt_lower:
return '这是一段关于内容深度分析的重要讨论,涵盖了多个关键主题和见解。'
return '模拟AI响应内容'
def _parse_json_from_text(self, text: str) -> Dict[str, Any]:
"""从文本中解析 JSON"""
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 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") -> AnalysisResult:
"""
分析内容
Args:
metadata: 元数据字典
content: 内容数据字典(包含 subtitle, description 等)
content_type: 内容类型
Returns:
AnalysisResult 分析结果
"""
title = metadata.get('title', '未知')
duration = metadata.get('duration', 0)
subtitle = content.get('subtitle', '')
description = content.get('description', '')
print(f"[DeepAnalyzer] 开始分析: {title}")
print(f"[DeepAnalyzer] 类型: {content_type}, 时长: {duration}秒")
print(f"[DeepAnalyzer] 字幕存在: {bool(subtitle and subtitle.strip())}")
has_subtitle = bool(subtitle and subtitle.strip())
if has_subtitle:
if duration < self.SHORT_VIDEO_THRESHOLD:
print(f"[DeepAnalyzer] 使用轻量分析策略 (时长 < 10分钟)")
result = self._analyze_short_with_subtitle(title, subtitle, duration)
else:
print(f"[DeepAnalyzer] 使用详细时间轴分析策略 (时长 >= 10分钟)")
result = self._analyze_long_with_subtitle(title, subtitle, duration, content_type)
else:
print(f"[DeepAnalyzer] 降级处理(无字幕)")
result = self._analyze_fallback(title, description, metadata)
return result
def _analyze_short_with_subtitle(self, title: str, subtitle: str, duration: int) -> AnalysisResult:
"""有字幕的短视频轻量分析"""
result = AnalysisResult()
text_sample = self._get_text_sample(subtitle, 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_simple_timeline_from_duration(title, duration)
return result
def _analyze_long_with_subtitle(self, title: str, subtitle: str, duration: int,
content_type: str) -> AnalysisResult:
"""有字幕的长视频详细分析"""
result = AnalysisResult()
if duration >= self.LONG_VIDEO_THRESHOLD and content_type == "video":
# 使用分段分析
result = self._analyze_in_chunks(title, subtitle, duration)
else:
# 直接分析
text_sample = self._get_text_sample(subtitle, 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_detailed_timeline(title, duration)
return result
def _analyze_in_chunks(self, title: str, subtitle: str, duration: int) -> AnalysisResult:
"""分段分析长视频"""
result = AnalysisResult()
# 确定分段数
if duration < 3600: # <60分钟
num_segments = 3
elif duration < 7200: # 60-120分钟
num_segments = 4
else: # >120分钟
num_segments = 6
# 生成分段摘要
segment_duration = duration // num_segments
chunk_summaries = []
# 简单按字符数分割字幕
subtitle_chunks = self._chunk_subtitle(subtitle, num_segments)
for i, chunk_text in enumerate(subtitle_chunks):
start_sec = i * segment_duration
end_sec = min((i + 1) * segment_duration, duration)
start_time = self._format_time(start_sec)
end_time = self._format_time(end_sec)
# AI 分析每段
chunk_summary = self._analyze_chunk(chunk_text, i + 1)
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_chunk(self, chunk_text: str, chunk_index: int) -> str:
"""分析单个分片"""
text_sample = self._get_text_sample(chunk_text, max_chars=3000)
prompt = f"""请对以下视频片段进行简要分析(限制在300字以内):
片段内容:
{text_sample}
请用中文简要回答,包括核心主题和关键要点。"""
try:
return self.ai_client.generate(prompt, max_tokens=500)
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
])
prompt = f"""基于以下视频各段分析,生成完整的分析报告:
标题:{title}
各段内容:
{summaries_text}
请返回JSON格式:
{{
"summary": "整体摘要(150字以内)",
"topics": ["主题1", "主题2", "主题3", "主题4"],
"insights": ["见解1", "见解2", "见解3"],
"actions": ["建议1", "建议2"],
"full_analysis": "详细分析(500字以内)"
}}"""
try:
analysis = self.ai_client.generate_json(prompt)
result = AnalysisResult()
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', summaries_text)
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 _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 短视频分析"""
prompt = f"""请分析以下视频内容,以JSON格式返回:
标题:{title}
时长:{self._format_time(duration)}
字幕内容:
{text[:3000]}
请返回以下格式JSON:
{{
"summary": "整体摘要(100字以内)",
"topics": ["主题1", "主题2", "主题3"],
"insights": ["见解1", "见解2"],
"actions": ["建议1"]
}}"""
try:
return self.ai_client.generate_json(prompt)
except Exception as e:
print(f"[DeepAnalyzer] AI分析失败: {e}")
return {
"summary": title,
"topics": ["主题1", "主题2", "主题3"],
"insights": ["无法获取深入见解"],
"actions": []
}
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 _chunk_subtitle(self, subtitle: str, num_chunks: int) -> List[str]:
"""简单分割字幕"""
if not subtitle:
return [""] * num_chunks
chunk_size = len(subtitle) // num_chunks
chunks = []
for i in range(num_chunks):
start = i * chunk_size
end = start + chunk_size if i < num_chunks - 1 else len(subtitle)
chunks.append(subtitle[start:end])
return chunks
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: 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}"
# ============== 模板系统 ==============
class TemplateLoader:
"""模板加载器"""
TEMPLATES = {
"video": """# {{title}}
## 基本信息
- **时长**: {{duration}}
- **作者**: {{author}}
- **来源**: {{source}}
- **分析时间**: {{analyze_time}}
## 摘要
{{summary}}
## 主要主题
{% for topic in topics %}
- {{topic}}
{% endfor %}
## 关键见解
{% for insight in insights %}
- {{insight}}
{% endfor %}
## 建议行动
{% for action in actions %}
- [ ] {{action}}
{% endfor %}
{% if timeline %}
## 时间轴
{% for entry in timeline %}
### {{entry.index}}. {{entry.start_time}} - {{entry.end_time}}
{{entry.title}}: {{entry.summary}}
{% endfor %}
{% endif %}
{% if tags %}
## 标签
{% for tag in tags %}
- {{tag}}
{% endfor %}
{% endif %}
""",
"article": """# {{title}}
## 基本信息
- **作者**: {{author}}
- **来源**: {{source}}
- **分析时间**: {{analyze_time}}
## 摘要
{{summary}}
## 主要主题
{% for topic in topics %}
- {{topic}}
{% endfor %}
## 关键见解
{% for insight in insights %}
- {{insight}}
{% endfor %}
## 建议行动
{% for action in actions %}
- [ ] {{action}}
{% endfor %}
{% if tags %}
## 标签
{% for tag in tags %}
- {{tag}}
{% endfor %}
{% endif %}
"""
}
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 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]
fm = ["---", "title: \"" + metadata.get('title', '未命名') + "\"",
f"date: {datetime.now().strftime('%Y-%m-%d')}",
f"duration: {format_duration(metadata.get('duration', 0))}",
f"author: {metadata.get('author', '未知')}"]
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="mock",
choices=["openai", "zhipu", "minimax", "mock"],
help="AI 提供商 (默认: mock)")
parser.add_argument("--model", default="gpt-4",
help="AI 模型 (默认: gpt-4)")
parser.add_argument("--no-frontmatter", action="store_true",
help="不生成 frontmatter")
args = parser.parse_args()
# 加载数据
print(f"[Main] 加载元数据: {args.metadata}")
metadata = load_json(args.metadata)
print(f"[Main] 加载内容: {args.content}")
content = load_json(args.content)
# 初始化
ai_client = SimpleAIClient(
api_key=args.api_key,
provider=args.provider,
model=args.model
)
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', '未命名'),
"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,
"timeline": result.timeline
}
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)
print(f"[Main] 分析完成!输出: {args.output}")
print(f"[Main] 摘要: {result.summary[:100]}...")
print(f"[Main] 主题数: {len(result.main_topics)}")
if __name__ == "__main__":
main()
+43
View File
@@ -0,0 +1,43 @@
# KB Link Parser Skill
**kb-link-parser** - Extract and resolve IDs from various platform URLs.
## Features
- Multi-platform support: Bilibili, YouTube, Zhihu
- Short link detection: b23.tv and other short links
- Multiple content types: video, article, up主, playlist, collection
- Batch processing from file
- JSON output format
## Commands
```bash
# Parse single URL
python link-parser.py --url "https://www.bilibili.com/video/BV1xx411c7mD"
# Batch processing
python link-parser.py --batch urls.txt --output results.json
# Output format
python link-parser.py --url "..." --format json
```
## Output
```json
{
"success": true,
"data": {
"platform": "bilibili",
"type": "video",
"id": "BV1xx411c7mD"
}
}
```
## Installation
```bash
cp -r kb-link-parser/ ~/skills/
```
+387
View File
@@ -0,0 +1,387 @@
#!/usr/bin/env python3
"""
Link Parser - Extract and resolve IDs from various platform URLs.
Supports:
- Bilibili (video, article, up主, collection, series)
- YouTube (video, channel, playlist)
- Zhihu (question, answer, user, collection)
- Short links (b23.tv, etc.)
Usage:
python link-parser.py --url "https://www.bilibili.com/video/BV1xx411c7mD"
python link-parser.py --batch urls.txt --output results.json
"""
import argparse
import json
import re
import sys
from typing import Optional, Dict, Any
from urllib.parse import urlparse
from link_parser_shortlink import ShortLinkResolver
class LinkParser:
"""Main link parser class"""
# Bilibili patterns
BILIBILI_VIDEO_PATTERN = re.compile(r'https?://(?:www\.)?bilibili\.com/video/(BV[a-zA-Z0-9]+)')
BILIBILI_VIDEO_SHORT_PATTERN = re.compile(r'https?://b23\.tv/([a-zA-Z0-9]+)')
BILIBILI_ARTICLE_PATTERN = re.compile(r'https?://(?:www\.)?bilibili\.com/article/([a-zA-Z0-9]+)')
BILIBILI_SPACE_PATTERN = re.compile(r'https?://(?:space\.)?bilibili\.com/(\d+)')
BILIBILI_FAVEPATTERN = re.compile(r'https?://(?:www\.)?bilibili\.com/opus/(\d+)')
BILIBILI_SERIES_PATTERN = re.compile(r'https?://(?:www\.)?bilibili\.com/series/([a-zA-Z0-9]+)')
# YouTube patterns
YOUTUBE_VIDEO_PATTERN = re.compile(r'(?:https?://)?(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11})')
YOUTUBE_CHANNEL_PATTERN = re.compile(r'(?:https?://)?(?:www\.)?youtube\.com/channel/([a-zA-Z0-9_-]+)')
YOUTUBE_USER_PATTERN = re.compile(r'(?:https?://)?(?:www\.)?youtube\.com/user/([a-zA-Z0-9_-]+)')
YOUTUBE_PLAYLIST_PATTERN = re.compile(r'(?:https?://)?(?:www\.)?youtube\.com/playlist\?list=([a-zA-Z0-9_-]+)')
# Zhihu patterns
ZHIHAU_QUESTION_PATTERN = re.compile(r'https?://(?:www\.)?zhihu\.com/question/(\d+)')
ZHIHAU_ANSWER_PATTERN = re.compile(r'https?://(?:www\.)?zhihu\.com/question/\d+/answer/(\d+)')
ZHIHAU_USER_PATTERN = re.compile(r'https?://(?:www\.)?zhihu\.com/people/([a-zA-Z0-9_-]+)')
ZHIHAU_COLUMN_PATTERN = re.compile(r'https?://(?:www\.)?zhihu\.com/column/([a-zA-Z0-9_-]+)')
ZHIHAU_COLLECTION_PATTERN = re.compile(r'https?://(?:www\.)?zhihu\.com/collection/(\d+)')
def __init__(self):
self.shortlink_resolver = ShortLinkResolver()
def resolve_shortlink(self, url: str) -> Optional[str]:
"""
Resolve short link to get final URL.
Args:
url: Short URL to resolve
Returns:
Final URL after resolution, or None if not resolvable
"""
# Check if it's a known short link pattern
if self.shortlink_resolver.is_bilibili_short_link(url):
# For b23.tv, we need Playwright to resolve
# Return the short URL as-is with a note
return url
# Return original URL for other cases
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 video
match = self.BILIBILI_VIDEO_PATTERN.match(url)
if match:
return {
"platform": "bilibili",
"type": "video",
"id": match.group(1)
}
# Try article
match = self.BILIBILI_ARTICLE_PATTERN.match(url)
if match:
return {
"platform": "bilibili",
"type": "article",
"id": match.group(1)
}
# Try space (UP主)
match = self.BILIBILI_SPACE_PATTERN.match(url)
if match:
return {
"platform": "bilibili",
"type": "up主",
"id": match.group(1)
}
# Try collection
match = self.BILIBILI_FAVEPATTERN.match(url)
if match:
return {
"platform": "bilibili",
"type": "collection",
"id": match.group(1)
}
# Try series
match = self.BILIBILI_SERIES_PATTERN.match(url)
if match:
return {
"platform": "bilibili",
"type": "series",
"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)
}
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
# 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
result = link_parser.parse(args.url)
print(format_output(result, args.format))
return 0 if result["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(f"Error reading file: {e}", file=sys.stderr)
return 1
results = []
for url in urls:
result = link_parser.parse(url)
results.append(result)
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"Results written to {args.output}")
else:
for result in results:
print(format_output(result, args.format))
print("-" * 40)
return 0
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""
Short link resolver for link-parser skill.
Provides utilities for detecting and resolving short links, particularly
Bilibili short links (b23.tv) which may be blocked by anti-scraping measures.
Supported short link services:
- Bilibili: b23.tv
- Generic URL shorteners (detection only)
"""
import re
from typing import Optional, Dict, List, Tuple
from urllib.parse import urlparse
class ShortLinkResolver:
"""Short link detection and resolution handler"""
# Pattern for Bilibili short links
BILIBILI_SHORT_PATTERN = re.compile(r'https?://b23\.tv/([a-zA-Z0-9]+)')
# Pattern for other short link services
OTHER_SHORT_PATTERNS = {
'bit.ly': re.compile(r'https?://bit\.ly/\w+'),
't.cn': re.compile(r'https?://t\.cn/\w+'),
'dwz.cn': re.compile(r'https?://dwz\.cn/\w+'),
'goo.gl': re.compile(r'https?://goo\.gl/\w+'),
'tinyurl.com': re.compile(r'https?://tinyurl\.com/\w+'),
}
@staticmethod
def detect_short_links(text: str) -> List[Dict[str, str]]:
"""
Detect short links in text.
Args:
text: Input text to search
Returns:
List of detected short links with metadata:
[
{
'url': 'https://b23.tv/xxxxx',
'platform': 'bilibili',
'is_short': True
},
...
]
"""
detected = []
# Check for Bilibili short links
for match in ShortLinkResolver.BILIBILI_SHORT_PATTERN.finditer(text):
detected.append({
'url': match.group(0),
'code': match.group(1),
'platform': 'bilibili',
'is_short': True
})
# Check for other short link services
for platform, pattern in ShortLinkResolver.OTHER_SHORT_PATTERNS.items():
for match in pattern.finditer(text):
detected.append({
'url': match.group(0),
'platform': platform,
'is_short': True
})
return detected
@staticmethod
def is_bilibili_short_link(url: str) -> bool:
"""
Check if URL is a Bilibili short link.
Args:
url: URL to check
Returns:
True if URL is a b23.tv short link
"""
return bool(ShortLinkResolver.BILIBILI_SHORT_PATTERN.match(url))
@staticmethod
def format_short_link_help(short_link: Optional[str] = None) -> str:
"""
Format help message for short links.
Args:
short_link: Optional short link URL to mention
Returns:
Help message
"""
lines = [
"Detection: Short link",
""
]
if short_link:
lines.append(f"Short link: {short_link}")
lines.append("")
lines.extend([
"Due to anti-scraping protection, short links cannot be automatically resolved.",
"Please provide the full URL or the content ID (e.g., BV号 for Bilibili).",
"",
"Full URL format: https://www.bilibili.com/video/BVxxxxx"
])
return "\n".join(lines)
@staticmethod
def format_short_link_warning(short_links: List[Dict[str, str]]) -> str:
"""
Format warning message for multiple short links.
Args:
short_links: List of short link metadata
Returns:
Warning message
"""
if not short_links:
return ""
lines = [
"Short links detected:",
""
]
for i, link in enumerate(short_links, 1):
platform = link.get('platform', 'unknown')
url = link['url']
if platform == 'bilibili':
lines.append(f" [{i}] Bilibili short link: {url}")
lines.append(" Full URL or BV ID required")
else:
lines.append(f" [{i}] {platform}: {url}")
lines.append("")
return "\n".join(lines)
class PlaywrightResolver:
"""
Browser-based short link resolver using Playwright.
This is an optional implementation for environments where Playwright
is available. It can bypass anti-scraping measures by simulating
a real browser.
Requirements:
pip install playwright
playwright install chromium
"""
def __init__(self, headless: bool = True, timeout: int = 10000):
"""
Initialize Playwright resolver.
Args:
headless: Whether to run browser in headless mode
timeout: Page navigation timeout in milliseconds
"""
self.headless = headless
self.timeout = timeout
self._playwright = None
def _check_playwright_available(self) -> bool:
"""Check if Playwright is installed and available"""
try:
from playwright.sync_api import sync_playwright
return True
except ImportError:
return False
def resolve(self, short_url: str) -> Optional[str]:
"""
Resolve short link using Playwright browser automation.
Args:
short_url: Short URL to resolve
Returns:
Final URL after redirects, or None if resolution fails
"""
if not self._check_playwright_available():
raise ImportError(
"Playwright not installed. Install with: pip install playwright && playwright install chromium"
)
from playwright.sync_api import sync_playwright
try:
with sync_playwright() as p:
# Launch browser
browser = p.chromium.launch(headless=self.headless)
context = browser.new_context(
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
viewport={'width': 1920, 'height': 1080}
)
page = context.new_page()
# Navigate to short URL
page.goto(short_url, timeout=self.timeout, wait_until='networkidle')
# Get final URL
final_url = page.url
# Close browser
browser.close()
return final_url
except Exception as e:
print(f"Playwright resolution failed: {e}")
return None
def resolve_bilibili_short(self, short_url: str) -> Optional[str]:
"""
Resolve Bilibili short link and extract BV ID.
Args:
short_url: Bilibili short URL (e.g., https://b23.tv/xxxxx)
Returns:
Full Bilibili URL with BV ID, or None if resolution fails
"""
final_url = self.resolve(short_url)
if not final_url:
return None
# Check if final URL contains BV ID
if 'bilibili.com/video/' in final_url:
return final_url
# Extract BV ID if present in URL
bv_pattern = re.compile(r'(BV[a-zA-Z0-9]{10})')
match = bv_pattern.search(final_url)
if match:
return f"https://www.bilibili.com/video/{match.group(1)}"
return None
def get_resolver() -> ShortLinkResolver:
"""
Get the default resolver instance.
Returns:
ShortLinkResolver instance
"""
return ShortLinkResolver()
def detect_short_links(text: str) -> List[Dict[str, str]]:
"""
Detect short links in text.
Args:
text: Input text to search
Returns:
List of detected short links with metadata
"""
resolver = ShortLinkResolver()
return resolver.detect_short_links(text)
# CLI interface for testing
def main():
"""CLI for testing the short link resolver."""
import sys
if len(sys.argv) < 2:
print("Usage: python3 link_parser_shortlink.py 'text to test'")
print("\nExamples:")
print(' python3 link_parser_shortlink.py "Check this: https://b23.tv/oLBZZfK"')
print(' python3 link_parser_shortlink.py "https://bit.ly/xxxxx"')
sys.exit(1)
text = ' '.join(sys.argv[1:])
short_links = detect_short_links(text)
import json
print(json.dumps(short_links, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
+43
View File
@@ -0,0 +1,43 @@
# KB OCR Processor Skill
**kb-ocr-processor** - Extract text from video frames using OCR.
## Features
- Batch frame processing
- PaddleOCR/Tesseract support
- Confidence scoring
- Text merging
## Commands
```bash
# Process frames
python ocr_processor.py --input ./frames/ --output ocr.json
# With custom FPS
python ocr_processor.py --input ./frames/ --fps 0.1 --output ocr.json
```
## Output
```json
{
"success": true,
"data": {
"frames": {
"frame_0001.jpg": {
"texts": [{"text": "字幕", "confidence": 0.95}]
}
},
"merged_text": "合并的字幕文本..."
}
}
```
## Installation
```bash
cp -r kb-ocr-processor/ ~/skills/
pip install -r requirements.txt
```
+242
View File
@@ -0,0 +1,242 @@
#!/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) -> List[Dict]:
"""
处理目录下所有帧图片
Args:
input_dir: 帧图片目录
fps: 采样帧率
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 []
print(f"Found {len(image_files)} images in {input_dir}")
print(f"Processing with {self.engine} engine...")
results = []
for i, image_path in enumerate(image_files):
print(f"Processing [{i+1}/{len(image_files)}]: {image_path}")
ocr_result = self.process_image(image_path)
ocr_result["source"] = os.path.basename(image_path)
results.append(ocr_result)
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 main():
"""命令行入口"""
parser = argparse.ArgumentParser(
description="OCR 视频帧文字识别工具",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
# 使用 EasyOCR 处理帧目录
python ocr_processor.py --input ./frames/ --output ./ocr.json
# 使用 Tesseract 处理
python ocr_processor.py --input ./frames/ --output ./ocr.json --engine tesseract
# 指定采样帧率
python ocr_processor.py --input ./frames/ --output ./ocr.json --fps 0.5
# 同时生成合并的文本文件
python ocr_processor.py --input ./frames/ --output ./ocr.json --merge
"""
)
parser.add_argument("--input", "-i", required=True, help="输入帧图片目录")
parser.add_argument("--output", "-o", required=True, help="输出 JSON 文件路径")
parser.add_argument("--fps", "-f", type=float, default=0.1, help="采样帧率 (默认: 0.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="语言列表")
args = parser.parse_args()
# 初始化处理器
processor = OCRProcessor(engine=args.engine, languages=args.languages)
# 处理帧
results = processor.process_frames(args.input, fps=args.fps)
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
print(f"\n=== 摘要 ===")
print(f"处理帧数: {len(results)}")
print(f"识别到文字的帧: {total_texts}")
print(f"平均置信度: {avg_conf:.2%}")
else:
print("未处理到任何结果")
return 1
return 0
if __name__ == "__main__":
exit(main())
+3
View File
@@ -0,0 +1,3 @@
easyocr>=1.7.0
pytesseract>=0.3.10
Pillow>=9.0.0
+285
View File
@@ -0,0 +1,285 @@
---
name: kb-workflow
description: 知识库工作流编排技能,提供收件箱扫描、内容处理、归档、订阅检测和汇报生成功能。
metadata:
short-description: 知识库工作流编排
---
# KB Workflow
## Description
KB Workflow 是一个知识库工作流编排技能,负责协调和管理知识库内容的完整生命周期:
**核心功能**:
- 📥 **收件箱扫描** - 扫描四个子目录 (pending, metadata, content, analyzed)
- 🔄 **内容处理** - 调用技能2-4处理内容
- 📦 **智能归档** - 将处理完成的内容归档到 archive/
- 🔔 **订阅检测** - 检测订阅源的更新
- 📊 **汇报生成** - 生成处理统计和推荐
## Workflow Stages
```
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌──────────┐
│ pending │ → │ metadata│ → │ content │ → │ analyzed│ → │ archive/ │
└─────────┘ └─────────┘ └─────────┘ └─────────┘ └──────────┘
↓ ↓ ↓ ↓
Skill 2 Skill 3 Skill 4 ↓
(Link解析) (元数据提取) (内容分析) 最终归档
```
## Installation
KB Workflow 作为 OpenClaw 技能套件的一部分,无需额外安装。
确保以下依赖已配置:
- Python 3.8+
- 知识库目录结构 (knowledge-base/)
## Directory Structure
```
knowledge-base/
├── pending/ # 待处理的新内容
├── metadata/ # 已提取元数据
├── content/ # 已提取内容
├── analyzed/ # 已完成分析
├── archive/ # 归档内容
│ └── YYYY-MM-DD/ # 按日期归档
└── reports/ # 生成的汇报
```
## Usage
### 命令行用法
```bash
# 查看工作流状态
python kb_workflow.py --trigger status
# 手动处理待处理内容
python kb_workflow.py --trigger manual --scope pending
# 处理所有新内容(限制5个)
python kb_workflow.py --trigger manual --limit 5
# 生成日报
python kb_workflow.py --trigger scheduled --report
# 检查订阅更新
python kb_workflow.py --trigger subscriptions
```
### 参数说明
| 参数 | 说明 | 默认值 |
|------|------|--------|
| `--trigger` | 触发类型 | `manual` |
| `--scope` | 处理范围 | `all` |
| `--limit` | 处理数量限制 | 无限制 |
| `--report` | 生成汇报 | False |
| `--output` | 输出格式 | `json` |
### 触发类型
- `manual` - 手动触发,立即处理
- `scheduled` - 定时任务触发
- `webhook` - Webhook 触发
- `status` - 仅查看状态
- `report` - 生成汇报
- `subscriptions` - 检查订阅
### 作用域
- `all` - 所有目录
- `pending` - 仅待处理
- `metadata` - 仅元数据阶段
- `content` - 仅内容阶段
- `analyzed` - 仅已完成分析
- `archive` - 归档目录
## Integration
### 技能2-4 集成
KB Workflow 协调以下子技能:
1. **Skill 2: Link Parser** - 解析 URL/链接
2. **Skill 3: Zhihu Metadata** - 提取元数据
3. **Skill 4: Bilibili Analyzer** - 内容分析
### 调用示例
```python
from kb_workflow import KBWorkflow
# 初始化
workflow = KBWorkflow(base_path="/path/to/knowledge-base")
# 扫描收件箱
inbox = workflow.scan_inbox(scope="pending")
print(f"待处理: {len(inbox['pending'])} 项")
# 处理项目
for item in inbox['pending'][:5]:
result = workflow.process_item(item)
print(f"处理结果: {result['success']}")
# 查看状态
status = workflow.get_status()
print(json.dumps(status, indent=2))
# 生成汇报
report = workflow.generate_report(period="daily")
```
## Input/Output Formats
### Input
#### 扫描收件箱输入
```json
{
"scope": "all", // 或 "pending", "metadata", "content", "analyzed"
"limit": 10 // 可选,限制返回数量
}
```
#### 处理项目输入
```json
{
"name": "example.md",
"path": "/path/to/knowledge-base/pending/example.md",
"extension": ".md",
"size": 1024,
"modified": "2026-02-10T12:00:00"
}
```
### Output
#### 状态输出
```json
{
"status": "ready",
"timestamp": "2026-02-10T12:00:00",
"inbox": {
"pending": {"count": 5, "path": "/path/pending"},
"metadata": {"count": 3, "path": "/path/metadata"},
"content": {"count": 2, "path": "/path/content"},
"analyzed": {"count": 8, "path": "/path/analyzed"}
},
"archive": {
"count": 120,
"path": "/path/archive"
},
"total_items": 18
}
```
#### 处理结果输出
```json
{
"item": {"name": "example.md", ...},
"success": true,
"stages_completed": ["link_parsing", "metadata_extraction", "content_analysis"],
"output_path": "/path/analyzed/example.md",
"error": null
}
```
#### 汇报输出
```json
{
"generated_at": "2026-02-10T12:00:00",
"period": "daily",
"summary": {
"total_items_inbox": 18,
"total_archived": 120,
"items_by_stage": {...}
},
"processing_stats": {...},
"recommendations": [
"⚠️ 待处理内容较多 (15 项),建议优先处理",
"📦 已分析内容较多 (25 项),建议归档"
]
}
```
## Configuration
KB Workflow 无需额外配置,自动使用以下路径:
- 知识库根目录: `skills/kb-workflow/knowledge-base/`
- 归档目录: `skills/kb-workflow/knowledge-base/archive/`
- 汇报目录: `skills/kb-workflow/knowledge-base/reports/`
### 自定义路径
```python
from kb_workflow import KBWorkflow
# 使用自定义路径
workflow = KBWorkflow(
base_path="/custom/knowledge-base",
archive_path="/custom/archive",
output_format="json"
)
```
## Workflow Steps
### 完整处理流程
1. **扫描收件箱**
- 扫描 `pending/` 目录获取新内容
- 扫描其他阶段目录获取待继续处理的内容
2. **分类处理**
- 新内容 → 提取链接/URL (Skill 2)
- 待提取元数据 → 提取元数据 (Skill 3)
- 待分析内容 → 深度分析 (Skill 4)
3. **自动流转**
- 处理完成后自动移动到下一阶段目录
4. **归档**
- 分析完成后可归档到 `archive/YYYY-MM-DD/`
5. **汇报**
- 生成处理统计
- 提供处理建议
### 定时任务示例
```bash
# 每天早上9点检查并处理新内容
0 9 * * * cd /path/to/skills/kb-workflow && python kb_workflow.py --trigger scheduled --scope pending --report
# 每小时检查订阅更新
0 * * * * cd /path/to/skills/kb-workflow && python kb_workflow.py --trigger subscriptions
```
## Troubleshooting
### 常见问题
**Q: 扫描不到内容?**
A: 确保知识库目录存在且包含正确的子目录 (pending, metadata, content, analyzed)
**Q: 处理失败?**
A: 检查日志输出,使用 `--verbose` 参数获取详细调试信息
**Q: 如何查看处理进度?**
A: 使用 `--trigger status` 查看各阶段的项目数量
## Related Skills
- **obsidian-kb** - Obsidian 知识库管理
- **link-parser** - 链接解析
- **zhihu-metadata** - 知乎元数据提取
- **bilibili-analyzer** - B站视频分析
---
*KB Workflow v1.0 - Knowledge Base Workflow Orchestration*
+427
View File
@@ -0,0 +1,427 @@
#!/usr/bin/env python3
"""
Auto-trigger handler for obsidian-kb skill.
Detects video/article links in user messages and automatically processes them.
Supports Bilibili, YouTube, and Zhihu.
"""
import re
import sys
import os
from typing import List, Dict, Optional, Tuple
from urllib.parse import urlparse
# Add path for imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'src'))
# Import short link resolver
from shortlink_resolver import (
ShortLinkResolver,
detect_and_format,
PlaywrightResolver
)
# 导入主流程
try:
from main_pipeline import MainPipeline
MAIN_PIPELINE_AVAILABLE = True
except ImportError:
MAIN_PIPELINE_AVAILABLE = False
# Pattern definitions for auto-detection
PATTERNS = {
'bilibili_full': re.compile(r'https?://(?:www\.)?bilibili\.com/video/(BV\w+)'),
'bilibili_short': re.compile(r'https?://b23\.tv/(\w+)'),
'bilibili_bv': re.compile(r'\b(BV[a-zA-Z0-9]{10})\b'),
'youtube_full': re.compile(r'https?://(?:www\.)?youtube\.com/watch\?v=([a-zA-Z0-9_-]{11})'),
'youtube_short': re.compile(r'https?://youtu\.be/([a-zA-Z0-9_-]{11})'),
'zhihu_article': re.compile(r'https?://zhuanlan\.zhihu\.com/p/(\d+)'),
'zhihu_answer': re.compile(r'https?://(?:www\.)?zhihu\.com/question/\d+/answer/(\d+)'),
}
def detect_links(text: str) -> List[Dict[str, str]]:
"""
Detect video/article links in text.
Args:
text: Input text to search
Returns:
List of detected links with metadata:
[
{
'url': 'https://...',
'platform': 'bilibili',
'type': 'video',
'id': 'BVxxx',
'is_short': False
},
...
]
"""
detected = []
seen_ids = set() # Track IDs to avoid duplicates
for pattern_name, pattern in PATTERNS.items():
matches = pattern.finditer(text)
for match in matches:
# Determine platform and type
if 'bilibili' in pattern_name:
platform = 'bilibili'
content_type = 'video'
if 'short' in pattern_name:
is_short = True
url = match.group(0)
content_id = None # Need to resolve short URL
else:
is_short = False
if pattern_name == 'bilibili_bv':
url = f"https://www.bilibili.com/video/{match.group(1)}"
else:
url = match.group(0)
content_id = match.group(1)
elif 'youtube' in pattern_name:
platform = 'youtube'
content_type = 'video'
is_short = 'short' in pattern_name
url = match.group(0)
content_id = match.group(1)
elif 'zhihu' in pattern_name:
platform = 'zhihu'
content_type = 'article'
is_short = False
url = match.group(0)
content_id = match.group(1)
else:
continue
# Skip duplicates by URL or ID
unique_key = url if (not content_id or is_short) else content_id
if unique_key in seen_ids:
continue
seen_ids.add(unique_key)
detected.append({
'url': url,
'platform': platform,
'type': content_type,
'id': content_id,
'is_short': is_short,
'pattern': pattern_name
})
return detected
def format_preview(detected: List[Dict[str, str]]) -> str:
"""
Format a preview message for detected links.
Args:
detected: List of detected links
Returns:
Formatted preview message
"""
if not detected:
return ""
lines = ["🔍 检测到内容链接:\n"]
for i, item in enumerate(detected, 1):
platform_emoji = {
'bilibili': '📺',
'youtube': '▶️',
'zhihu': '📝'
}.get(item['platform'], '🔗')
platform_name = {
'bilibili': 'B站视频',
'youtube': 'YouTube视频',
'zhihu': '知乎文章'
}.get(item['platform'], item['platform'])
lines.append(f"{platform_emoji} [{i}] {platform_name}")
lines.append(f" URL: {item['url']}")
if item['is_short']:
lines.append(f" ⚠️ 短链接,需要用户提供完整URL或ID")
elif item['id']:
lines.append(f" ID: {item['id']}")
lines.append("")
return "\n".join(lines)
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
"""
return ShortLinkResolver.format_short_link_help(short_link)
def get_action_prompt(detected: List[Dict[str, str]]) -> str:
"""
Generate prompt asking user what to do.
Args:
detected: List of detected links
Returns:
Action prompt
"""
has_short = any(item['is_short'] for item in detected)
if has_short:
return f"{format_short_link_help()}\n\n其他链接是否立即处理?(y/n)"
count = len(detected)
if count == 1:
return "是否添加到收件箱并处理?(y/n)"
else:
return f"是否将上述 {count} 个内容添加到收件箱?(y/n)"
def should_auto_process(text: str) -> bool:
"""
Check if user wants to auto-process based on their message.
Args:
text: User's message
Returns:
True if user indicates they want immediate processing
"""
auto_keywords = ['立即处理', '处理', 'process', '--process', '马上']
return any(keyword in text.lower() for keyword in auto_keywords)
def process_link(url: str, platform: str, auto_process: bool = False) -> Tuple[bool, str]:
"""
Process a single link (extract metadata, add to inbox).
Args:
url: Content URL
platform: Platform name (bilibili/youtube/zhihu)
auto_process: Whether to immediately process after adding
Returns:
(success, message) tuple
"""
try:
# Import here to avoid issues if dependencies not available
from sources.bilibili import BilibiliAdapter
from models import Content, ContentStatus
from storage import Storage
# Only support Bilibili for now
if platform != 'bilibili':
return False, f"暂不支持 {platform},请手动添加"
# Handle short links
if 'b23.tv' in url:
return False, "短链接无法自动处理,请提供完整URL或BV号"
# Create adapter
adapter = BilibiliAdapter()
# Extract metadata
content = adapter.extract_content(url)
if not content:
return False, "无法提取视频信息"
# Get transcript
transcript = adapter.get_transcript(content)
if transcript:
content.transcript = transcript
# Save to inbox
content.status = ContentStatus.CONFIRMED
storage = Storage()
storage.save_content(content)
# Build success message
msg = f"✅ 已添加到收件箱\n"
msg += f" 标题: {content.title}\n"
msg += f" UP主: {content.author}\n"
msg += f" 时长: {content.duration}秒\n"
if auto_process:
# Process immediately
try:
from src.pipeline import ProcessingPipeline
pipeline = ProcessingPipeline()
content_text = content.transcript or content.summary or ""
result = pipeline.process_full(content, content_text)
storage.save_content(result)
msg += f"\n🔄 处理完成\n"
msg += f" 📄 笔记: {result.output_path or 'N/A'}\n"
msg += f" 🏷️ 标签: {', '.join(result.tags)}"
except Exception as e:
msg += f"\n⚠️ 处理失败: {e}"
return True, msg
except ImportError as e:
return False, f"依赖模块未安装: {e}"
except Exception as e:
return False, f"处理失败: {e}"
def process_video_full(source_id: str, enable_git_sync: bool = False) -> Tuple[bool, str]:
"""
使用主流程完整处理视频(新接口)
完整流程: 提取 → 处理 → 输出 → 同步
Args:
source_id: 视频ID(支持BV号、URL)
enable_git_sync: 是否启用Git同步
Returns:
(success, message) tuple
"""
if not MAIN_PIPELINE_AVAILABLE:
return False, "主流程模块未可用,请检查依赖"
try:
# 导入配置
from src.config import get_config
# 获取配置
config = get_config()
# 创建主流程
pipeline = MainPipeline(config=config, enable_git_sync=enable_git_sync)
# 处理视频
result = pipeline.process_video(source_id)
# 返回结果
if result['success']:
return True, result['message']
else:
return False, result.get('error', 'Unknown error')
except Exception as e:
return False, f"处理失败: {e}"
def handle_message(text: str) -> Optional[str]:
"""
Main entry point: handle incoming message and return response.
Args:
text: User's message text
Returns:
Response message, or None if no action needed
"""
# First, check for short links and provide help
has_short, short_msg = detect_and_format(text)
# Detect all links (including short ones)
detected = detect_links(text)
if not detected:
return None
# If we have short links, show help message first
if has_short:
return short_msg
# Check if this is a confirmation (y/yes)
text_lower = text.lower().strip()
if text_lower in ('y', 'yes', '是', '好的', 'ok'):
# This would need state management - for now return a message
# about using explicit command
return "请使用 /kb-add <URL> 命令添加内容,或重新发送链接"
# Show preview
preview = format_preview(detected)
prompt = get_action_prompt(detected)
return f"{preview}\n{prompt}"
# CLI interface for testing
def main():
"""CLI for testing the handler."""
if len(sys.argv) < 2:
print("Usage: python3 handler.py <command> [options]")
print("\nCommands:")
print(" detect 'text' - 检测文本中的内容链接")
print(" process <BV/URL> - 使用主流程处理视频")
print(" process <BV/URL> --git - 处理视频并同步到Git")
print("\nExamples:")
print(' python3 handler.py detect "https://www.bilibili.com/video/BV1xx411c7mD"')
print(' python3 handler.py process BV1xx411c7mD')
print(' python3 handler.py process BV1xx411c7mD --git')
sys.exit(1)
command = sys.argv[1].lower()
if command == 'detect':
# 检测链接
if len(sys.argv) < 3:
print("Error: Please provide text to detect")
sys.exit(1)
text = ' '.join(sys.argv[2:])
response = handle_message(text)
if response:
print(response)
else:
print("No content links detected")
elif command == 'process':
# 使用主流程处理视频
if len(sys.argv) < 3:
print("Error: Please provide BV number or URL")
sys.exit(1)
source_id = sys.argv[2]
enable_git = '--git' in sys.argv
print(f"Processing video: {source_id}")
if enable_git:
print("Git sync: enabled")
else:
print("Git sync: disabled")
print()
success, message = process_video_full(source_id, enable_git_sync=enable_git)
if success:
print(message)
sys.exit(0)
else:
print(f"\n❌ Failed: {message}")
sys.exit(1)
else:
# 兼容旧格式:直接传入文本进行检测
text = sys.argv[1]
response = handle_message(text)
if response:
print(response)
else:
print("No content links detected")
if __name__ == '__main__':
main()
+489
View File
@@ -0,0 +1,489 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Obsidian KB - 主流程集成
整合提取层、处理层、输出层,实现完整的数据流:
提取(BilibiliExtractor) → 处理(DeepAnalyzer/ContentChunker) → 输出(ObsidianWriter) → 同步(GitSync)
"""
import os
import sys
import logging
from typing import Dict, Any, Optional
from datetime import datetime
from pathlib import Path
# 添加项目路径
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from src.extractors.bilibili_extractor import BilibiliExtractor, ExtractionError
from src.deep_analyzer import DeepAnalyzer
from src.content_chunker import ContentChunker, Chunk
from src.outputs.obsidian_writer import ObsidianWriter
from src.outputs.git_sync import GitSync
from src.models import Content, ContentType, ContentStatus, TimelineEntry
from src.config import get_config
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler('obsidian_kb.log', encoding='utf-8')
]
)
logger = logging.getLogger('ObsidianKB')
class MainPipeline:
"""主流程集成器"""
def __init__(self, config=None, enable_git_sync: bool = False):
"""
初始化主流程
Args:
config: 配置对象
enable_git_sync: 是否启用Git同步
"""
self.config = config or get_config()
self.enable_git_sync = enable_git_sync
logger.info("初始化主流程...")
logger.info(f"Obsidian vault 路径: {self.config.vault_path}")
logger.info(f"Git 同步: {'启用' if enable_git_sync else '禁用'}")
# 初始化各层组件
self.extractor = BilibiliExtractor(config=self.config)
self.analyzer = DeepAnalyzer(ai_client=None) # 暂时不传入ai_client
self.chunker = ContentChunker()
self.writer = ObsidianWriter(config=self.config)
# 初始化Git同步器(仅在需要时)
self.git_sync = None
if enable_git_sync and self._is_git_enabled():
self.git_sync = GitSync(config=self.config)
logger.info("Git 同步器已初始化")
def _is_git_enabled(self) -> bool:
"""检查Git同步是否在配置中启用"""
return self.config.get('git.enabled', False)
def process_video(self, source_id: str) -> Dict[str, Any]:
"""
处理单个视频的完整流程
Args:
source_id: 视频ID(支持BV号、URL)
Returns:
处理结果字典,包含:
- success: bool - 是否成功
- content: Content对象 - 处理的内容
- message: str - 处理消息
- error: str - 错误信息(如果失败)
"""
result = {
'success': False,
'content': None,
'message': '',
'error': ''
}
try:
logger.info("=" * 80)
logger.info(f"开始处理视频: {source_id}")
logger.info("=" * 80)
# Step 1: 提取层 - 提取视频信息
logger.info("\n[Step 1/5] 提取视频信息...")
extractor_data = self._extract_video(source_id)
# Step 2: 构建Content对象
logger.info("\n[Step 2/5] 构建内容对象...")
content = self._build_content(extractor_data)
# Step 3: 处理层 - 深度分析
logger.info("\n[Step 3/5] 深度分析...")
analysis_result = self._analyze_content(extractor_data)
# Step 4: 处理层 - 内容分片
logger.info("\n[Step 4/5] 内容分片...")
chunks = self._chunk_content(extractor_data)
# 合并分析结果和分片信息到content
self._merge_analysis_to_content(content, analysis_result, chunks)
# Step 5: 输出层 - 写入Obsidian
logger.info("\n[Step 5/5] 写入Obsidian...")
output_path = self._write_obsidian_note(content)
# Step 6: 同步层 - Git同步(可选)
if self.enable_git_sync and self.git_sync:
logger.info("\n[Step 6/6] Git同步...")
self._sync_to_git(content)
# 更新状态
content.update_status(ContentStatus.GENERATED)
content.output_path = output_path
# 构建成功结果
result['success'] = True
result['content'] = content
result['message'] = self._format_success_message(content, chunks)
logger.info("\n" + "=" * 80)
logger.info(f"✅ 处理完成: {content.title}")
logger.info(f" 📄 笔记路径: {output_path}")
logger.info(f" 📊 字幕条数: {len(chunks) if chunks else 0}")
logger.info("=" * 80 + "\n")
except ExtractionError as e:
logger.error(f"❌ 提取失败: {e}")
result['error'] = f"提取失败: {e}"
result['message'] = f"提取视频信息失败: {e}"
except Exception as e:
logger.error(f"❌ 处理失败: {e}", exc_info=True)
result['error'] = str(e)
result['message'] = f"处理过程中发生错误: {e}"
return result
def _extract_video(self, source_id: str) -> Dict[str, Any]:
"""
提取视频信息
Args:
source_id: 视频ID或URL
Returns:
提取器返回的数据结构
Raises:
ExtractionError: 提取失败
"""
logger.info(f" 正在提取: {source_id}")
try:
extractor_data = self.extractor.extract(source_id)
logger.info(f" ✅ 提取成功: {extractor_data.get('metadata', {}).get('title', '未知标题')}")
logger.info(f" 作者: {extractor_data.get('metadata', {}).get('author', '未知')}")
logger.info(f" 时长: {extractor_data.get('metadata', {}).get('duration', '0')}")
# 检查字幕
subtitle = extractor_data.get('subtitle', '')
if subtitle:
subtitle_lines = subtitle.split('\n')
logger.info(f" 字幕: {len(subtitle_lines)} 条")
else:
logger.warning(f" ⚠️ 无字幕")
return extractor_data
except ExtractionError as e:
logger.error(f" ❌ 提取失败: {e}")
raise
except Exception as e:
logger.error(f" ❌ 提取异常: {e}", exc_info=True)
raise ExtractionError(f"提取过程异常: {e}", source_id=source_id)
def _build_content(self, extractor_data: Dict[str, Any]) -> Content:
"""
从提取器数据构建Content对象
Args:
extractor_data: 提取器返回的数据
Returns:
Content对象
"""
metadata = extractor_data.get('metadata', {})
content = Content(
source='bilibili',
source_id=metadata.get('bvid', ''),
url=metadata.get('url', ''),
title=metadata.get('title', ''),
author=metadata.get('author', ''),
description=extractor_data.get('description', ''),
duration=self._parse_duration(metadata.get('duration', '0')),
content_type=ContentType.VIDEO,
transcript=extractor_data.get('subtitle', ''),
status=ContentStatus.SCANNED
)
logger.info(f" ✅ Content对象已创建: {content.id}")
return content
def _parse_duration(self, duration_str: str) -> Optional[int]:
"""
解析时长字符串为秒数
Args:
duration_str: 时长字符串(如 "10:30")
Returns:
秒数
"""
try:
if ':' in duration_str:
parts = duration_str.split(':')
if len(parts) == 2:
# MM:SS
return int(parts[0]) * 60 + int(parts[1])
elif len(parts) == 3:
# HH:MM:SS
return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
except:
pass
return None
def _analyze_content(self, extractor_data: Dict[str, Any]) -> Dict[str, Any]:
"""
深度分析内容
Args:
extractor_data: 提取器返回的数据
Returns:
分析结果字典
"""
try:
logger.info(" 正在进行深度分析...")
# 使用DeepAnalyzer进行分析
# DeepAnalyzer接收extractor_data作为输入
analysis_result = self.analyzer.analyze(extractor_data)
# 将AnalysisResult转换为字典以便合并
result_dict = {
'main_topics': analysis_result.main_topics,
'key_insights': analysis_result.key_insights,
'action_items': analysis_result.action_items,
'summary': analysis_result.summary,
'timeline': analysis_result.timeline,
'recommended_tags': analysis_result.recommended_tags,
'full_analysis': analysis_result.full_analysis
}
logger.info(f" ✅ 分析完成: {len(result_dict['main_topics'])} 个主题")
return result_dict
except Exception as e:
logger.error(f" ❌ 分析失败: {e}", exc_info=True)
# 返回默认结果,不中断流程
return {
'main_topics': ['未分类'],
'key_insights': [],
'action_items': [],
'summary': '分析失败,无法生成摘要',
'timeline': [],
'recommended_tags': ['未分类'],
'full_analysis': ''
}
def _chunk_content(self, extractor_data: Dict[str, Any]) -> list:
"""
内容分片
Args:
extractor_data: 提取器返回的数据
Returns:
Chunk对象列表
"""
try:
logger.info(" 正在进行内容分片...")
# 使用ContentChunker进行分片
chunks = self.chunker.chunk_from_extractor(extractor_data)
logger.info(f" ✅ 分片完成: {len(chunks)} 段")
return chunks
except Exception as e:
logger.error(f" ❌ 分片失败: {e}", exc_info=True)
# 返回空列表,不中断流程
return []
def _merge_analysis_to_content(self, content: Content, analysis_result: Dict[str, Any], chunks: list):
"""
将分析结果和分片信息合并到Content对象
Args:
content: Content对象
analysis_result: 分析结果字典
chunks: Chunk对象列表
"""
# 合并基本信息
content.summary = analysis_result.get('summary', '')
content.key_points = analysis_result.get('main_topics', [])
content.tags = analysis_result.get('recommended_tags', [])
# 合并时间轴
timeline_data = analysis_result.get('timeline', [])
if timeline_data:
content.timeline = [
TimelineEntry(
timestamp=item.get('start_time', ''),
title=item.get('title', ''),
description=item.get('summary', '')
)
for item in timeline_data
]
else:
# 从chunks生成时间轴
if chunks:
content.timeline = [
TimelineEntry(
timestamp=chunk.time_range,
title=f"片段 {chunk.index + 1}",
description=f"时长 {int(chunk.duration)}秒"
)
for chunk in chunks
]
logger.info(f" ✅ 数据合并完成: 时间轴 {len(content.timeline)} 条")
def _write_obsidian_note(self, content: Content) -> Optional[str]:
"""
写入Obsidian笔记
Args:
content: Content对象
Returns:
文件路径,失败返回None
"""
try:
logger.info(f" 正在写入笔记...")
# 确保输出目录存在
self.config.vault_path.mkdir(parents=True, exist_ok=True)
# 调用ObsidianWriter写入
output_path = self.writer.write(content, self.config)
if output_path:
logger.info(f" ✅ 笔记已写入: {output_path}")
else:
logger.warning(f" ⚠️ 笔记写入失败")
return output_path
except Exception as e:
logger.error(f" ❌ 写入失败: {e}", exc_info=True)
return None
def _sync_to_git(self, content: Content):
"""
同步到Git仓库
Args:
content: Content对象
"""
if not self.git_sync:
logger.warning(" ⚠️ Git同步器未初始化,跳过同步")
return
try:
logger.info(" 正在同步到Git...")
success = self.git_sync.sync(self.config)
if success:
logger.info(" ✅ Git同步成功")
else:
logger.warning(" ⚠️ Git同步失败")
except Exception as e:
logger.error(f" ❌ Git同步异常: {e}", exc_info=True)
# Git同步失败不视为整体失败
def _format_success_message(self, content: Content, chunks: list) -> str:
"""
格式化成功消息
Args:
content: Content对象
chunks: Chunk列表
Returns:
格式化的消息字符串
"""
lines = [
"✅ 处理完成!",
"",
f"📺 标题: {content.title}",
f"👤 作者: {content.author}",
f"📄 笔记: {content.output_path or 'N/A'}",
]
if content.duration:
lines.append(f"⏱️ 时长: {self._format_duration(content.duration)}")
if chunks:
lines.append(f"📊 分片: {len(chunks)} 段")
if content.tags:
lines.append(f"🏷️ 标签: {', '.join(content.tags[:5])}")
lines.append("")
return "\n".join(lines)
def _format_duration(self, seconds: int) -> str:
"""格式化时长"""
if not seconds:
return "N/A"
hours = seconds // 3600
minutes = (seconds % 3600) // 60
secs = seconds % 60
if hours > 0:
return f"{hours}小时{minutes}分钟"
elif minutes > 0:
return f"{minutes}分钟{secs}秒"
else:
return f"{secs}秒"
def main():
"""命令行入口"""
import argparse
parser = argparse.ArgumentParser(description='Obsidian KB - 主流程集成')
parser.add_argument('source_id', help='视频ID(BV号或URL)')
parser.add_argument('--git', action='store_true', help='启用Git同步')
parser.add_argument('--config', help='配置文件路径')
args = parser.parse_args()
# 加载配置
config = get_config(args.config) if args.config else get_config()
# 创建主流程
pipeline = MainPipeline(config=config, enable_git_sync=args.git)
# 处理视频
result = pipeline.process_video(args.source_id)
# 输出结果
print(result['message'])
if not result['success']:
print(f"\n❌ 失败: {result['error']}")
sys.exit(1)
else:
sys.exit(0)
if __name__ == '__main__':
main()
+674
View File
@@ -0,0 +1,674 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
KB Workflow - Knowledge Base Workflow Orchestration
工作流编排技能,整合以下功能:
- 扫描收件箱目录 (pending, metadata, content, analyzed)
- 调用技能2-4处理内容
- 归档处理完成的条目
- 订阅更新检测
- 生成汇报
Usage:
python kb_workflow.py --trigger manual
python kb_workflow.py --trigger scheduled
python kb_workflow.py --status
python kb_workflow.py --report
python kb_workflow.py --scope pending --limit 5
"""
import os
import sys
import json
import argparse
import logging
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional, Any
from enum import Enum
# Setup logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger('KBWorkflow')
class TriggerType(Enum):
MANUAL = "manual"
SCHEDULED = "scheduled"
WEBHOOK = "webhook"
class Scope(Enum):
ALL = "all"
PENDING = "pending"
METADATA = "metadata"
CONTENT = "content"
ANALYZED = "analyzed"
ARCHIVE = "archive"
class KBWorkflow:
"""Knowledge Base Workflow Orchestrator"""
# Base directories for the inbox structure
INBOX_DIRS = ['pending', 'metadata', 'content', 'analyzed']
def __init__(self,
base_path: str = None,
archive_path: str = None,
output_format: str = "json"):
"""
Initialize KB Workflow
Args:
base_path: Base path for knowledge base (default: workspace/knowledge-base)
archive_path: Path for archived items (default: archive/)
output_format: Output format (json, text)
"""
self.base_path = Path(base_path) if base_path else Path(__file__).parent / "knowledge-base"
self.archive_path = Path(archive_path) if archive_path else self.base_path / "archive"
self.output_format = output_format
# Ensure directories exist
self._ensure_directories()
# Initialize subsystems
self._init_subsystems()
def _ensure_directories(self):
"""Create necessary directories"""
# Create main inbox directories
for subdir in self.INBOX_DIRS:
dir_path = self.base_path / subdir
if not dir_path.exists():
dir_path.mkdir(parents=True, exist_ok=True)
logger.info(f"Created directory: {dir_path}")
# Create archive directory
if not self.archive_path.exists():
self.archive_path.mkdir(parents=True, exist_ok=True)
logger.info(f"Created archive directory: {self.archive_path}")
def _init_subsystems(self):
"""Initialize subsystem references"""
# Placeholder for skill integrations
self.subscriptions_file = self.base_path / "subscriptions.json"
self.report_file = self.base_path / "reports" / f"report_{datetime.now().strftime('%Y%m%d')}.json"
# ==================== Inbox Scanning ====================
def scan_inbox(self, scope: str = "all", limit: int = None) -> Dict[str, List[Dict]]:
"""
Scan inbox directories for items
Args:
scope: Which directories to scan (all, pending, metadata, content, analyzed)
limit: Maximum number of items to return per directory
Returns:
Dictionary with directory names as keys and item lists as values
"""
result = {}
if scope == "all":
dirs_to_scan = self.INBOX_DIRS
elif scope in self.INBOX_DIRS:
dirs_to_scan = [scope]
else:
logger.warning(f"Unknown scope: {scope}, scanning all")
dirs_to_scan = self.INBOX_DIRS
for dir_name in dirs_to_scan:
dir_path = self.base_path / dir_name
items = self._scan_directory(dir_path)
if limit:
items = items[:limit]
result[dir_name] = items
logger.info(f"Scanned {dir_name}: {len(items)} items")
return result
def _scan_directory(self, path: Path) -> List[Dict]:
"""
Scan a directory for items
Args:
path: Directory path to scan
Returns:
List of item dictionaries
"""
items = []
if not path.exists():
return items
for item_path in path.iterdir():
if item_path.is_file():
item_info = {
'name': item_path.name,
'path': str(item_path),
'extension': item_path.suffix,
'size': item_path.stat().st_size,
'modified': datetime.fromtimestamp(item_path.stat().st_mtime).isoformat(),
'type': self._classify_file_type(item_path)
}
items.append(item_info)
elif item_path.is_dir():
# Treat subdirectories as items too
item_info = {
'name': item_path.name,
'path': str(item_path),
'type': 'directory',
'modified': datetime.fromtimestamp(item_path.stat().st_mtime).isoformat()
}
items.append(item_info)
return items
def _classify_file_type(self, path: Path) -> str:
"""Classify file type by extension"""
suffix = path.suffix.lower()
type_mapping = {
'.md': 'markdown',
'.txt': 'text',
'.json': 'json',
'.yaml': 'yaml',
'.yml': 'yaml',
'.pdf': 'pdf',
'.jpg': 'image',
'.jpeg': 'image',
'.png': 'image',
'.mp4': 'video',
'.mp3': 'audio',
'.zip': 'archive',
'.url': 'link'
}
return type_mapping.get(suffix, 'unknown')
def get_status(self) -> Dict[str, Any]:
"""
Get current workflow status
Returns:
Status dictionary
"""
inbox_status = {}
total_items = 0
for dir_name in self.INBOX_DIRS:
dir_path = self.base_path / dir_name
items = self._scan_directory(dir_path)
inbox_status[dir_name] = {
'count': len(items),
'path': str(dir_path)
}
total_items += len(items)
# Check archive
archive_items = self._scan_directory(self.archive_path)
return {
'status': 'ready',
'timestamp': datetime.now().isoformat(),
'inbox': inbox_status,
'archive': {
'count': len(archive_items),
'path': str(self.archive_path)
},
'total_items': total_items
}
# ==================== Item Processing ====================
def process_item(self, item: Dict, target_stage: str = None) -> Dict[str, Any]:
"""
Process a single item through the workflow
This method calls skills 2-4 to process items:
- Skill 2: Link parsing / URL extraction
- Skill 3: Metadata extraction
- Skill 4: Content analysis
Args:
item: Item dictionary with path and metadata
target_stage: Target stage to process to
Returns:
Processing result dictionary
"""
result = {
'item': item,
'success': False,
'stages_completed': [],
'output_path': None,
'error': None
}
try:
item_path = Path(item['path'])
# Determine processing stage based on current location
current_stage = self._get_stage_from_path(item_path)
# Skill 2: Link/URL Parsing
if current_stage in ['pending', None] or target_stage == 'metadata':
logger.info(f"Stage 1 → 2: Processing link/URL for {item['name']}")
# TODO: Integrate with link-parser skill
result['stages_completed'].append('link_parsing')
# Skill 3: Metadata Extraction
if current_stage in ['metadata', 'pending'] or target_stage == 'content':
logger.info(f"Stage 2 → 3: Extracting metadata for {item['name']}")
# TODO: Integrate with zhihu-metadata skill
result['stages_completed'].append('metadata_extraction')
# Skill 4: Content Analysis
if current_stage in ['content', 'metadata'] or target_stage == 'analyzed':
logger.info(f"Stage 3 → 4: Analyzing content for {item['name']}")
# TODO: Integrate with bilibili-analyzer skill
result['stages_completed'].append('content_analysis')
# Move to next stage if successful
if result['stages_completed']:
self._move_to_next_stage(item_path, result['stages_completed'][-1])
result['success'] = True
except Exception as e:
result['error'] = str(e)
logger.error(f"Processing failed: {e}")
return result
def _get_stage_from_path(self, path: Path) -> Optional[str]:
"""Determine which stage a file is in based on its path"""
for stage in self.INBOX_DIRS:
if stage in str(path):
return stage
return None
def _move_to_next_stage(self, item_path: Path, completed_stage: str) -> None:
"""Move item to the next stage directory"""
stage_order = ['pending', 'metadata', 'content', 'analyzed']
try:
current_idx = stage_order.index(completed_stage)
if current_idx < len(stage_order) - 1:
next_stage = stage_order[current_idx + 1]
next_path = self.base_path / next_stage / item_path.name
# Move file
if item_path.exists():
item_path.rename(next_path)
logger.info(f"Moved {item_path.name} → {next_stage}/")
except ValueError:
logger.warning(f"Unknown stage: {completed_stage}")
def process_all(self, scope: str = "pending", limit: int = None) -> Dict[str, Any]:
"""
Process all items in specified scope
Args:
scope: Which directories to process
limit: Maximum items to process
Returns:
Processing summary
"""
summary = {
'scope': scope,
'processed': 0,
'failed': 0,
'results': []
}
inbox = self.scan_inbox(scope=scope)
for dir_name, items in inbox.items():
if limit and summary['processed'] >= limit:
break
for item in items:
if limit and summary['processed'] >= limit:
break
result = self.process_item(item)
summary['results'].append(result)
if result['success']:
summary['processed'] += 1
else:
summary['failed'] += 1
return summary
# ==================== Archiving ====================
def archive_item(self, item: Dict, analysis: Dict = None) -> Dict[str, Any]:
"""
Archive a processed item to the archive directory
Args:
item: Item to archive
analysis: Optional analysis metadata
Returns:
Archiving result
"""
result = {
'success': False,
'original_path': item['path'],
'archive_path': None,
'error': None
}
try:
item_path = Path(item['path'])
if not item_path.exists():
result['error'] = f"Item not found: {item['path']}"
return result
# Create archive subdirectory by date
date_dir = self.archive_path / datetime.now().strftime('%Y-%m-%d')
if not date_dir.exists():
date_dir.mkdir(parents=True, exist_ok=True)
# Move item to archive
archive_path = date_dir / item_path.name
item_path.rename(archive_path)
# Save analysis metadata if provided
if analysis:
meta_path = date_dir / f"{item_path.stem}_meta.json"
with open(meta_path, 'w', encoding='utf-8') as f:
json.dump({
'archived_at': datetime.now().isoformat(),
'analysis': analysis
}, f, ensure_ascii=False, indent=2)
result['success'] = True
result['archive_path'] = str(archive_path)
logger.info(f"Archived: {item['name']} → {archive_path}")
except Exception as e:
result['error'] = str(e)
logger.error(f"Archive failed: {e}")
return result
# ==================== Subscriptions ====================
def check_subscriptions(self) -> Dict[str, Any]:
"""
Check for updates from subscribed sources
Returns:
Subscription check results
"""
result = {
'checked_at': datetime.now().isoformat(),
'sources': {},
'new_items': 0,
'errors': []
}
# Load subscription list
subscriptions = self._load_subscriptions()
for sub in subscriptions:
try:
source_name = sub.get('name', 'unknown')
source_type = sub.get('type', 'rss')
# Placeholder: Check for new content
logger.info(f"Checking subscription: {source_name}")
# TODO: Implement actual subscription checking
# - RSS feeds
# - YouTube channel updates
# - Bilibili user updates
# - Custom web hooks
result['sources'][source_name] = {
'type': source_type,
'status': 'checked',
'new_items': 0
}
except Exception as e:
result['errors'].append({
'source': sub.get('name', 'unknown'),
'error': str(e)
})
return result
def _load_subscriptions(self) -> List[Dict]:
"""Load subscriptions from file"""
if self.subscriptions_file.exists():
try:
with open(self.subscriptions_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.warning(f"Failed to load subscriptions: {e}")
return []
# ==================== Reporting ====================
def generate_report(self, period: str = "daily") -> Dict[str, Any]:
"""
Generate workflow report
Args:
period: Report period (daily, weekly, monthly)
Returns:
Report dictionary
"""
# Calculate date range
now = datetime.now()
if period == "daily":
start_date = now - timedelta(days=1)
elif period == "weekly":
start_date = now - timedelta(weeks=1)
elif period == "monthly":
start_date = now - timedelta(days=30)
else:
start_date = now - timedelta(days=1)
# Gather statistics
status = self.get_status()
inbox = self.scan_inbox(scope="all")
# Build report
report = {
'generated_at': now.isoformat(),
'period': period,
'start_date': start_date.isoformat(),
'summary': {
'total_items_inbox': status['total_items'],
'total_archived': status['archive']['count'],
'items_by_stage': status['inbox']
},
'processing_stats': {
'pending': len(inbox.get('pending', [])),
'metadata': len(inbox.get('metadata', [])),
'content': len(inbox.get('content', [])),
'analyzed': len(inbox.get('analyzed', []))
},
'recommendations': self._generate_recommendations(status, inbox)
}
# Save report
if self.report_file:
self.report_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.report_file, 'w', encoding='utf-8') as f:
json.dump(report, f, ensure_ascii=False, indent=2)
logger.info(f"Report saved: {self.report_file}")
return report
def _generate_recommendations(self, status: Dict, inbox: Dict) -> List[str]:
"""Generate processing recommendations"""
recommendations = []
pending_count = status['inbox'].get('pending', {}).get('count', 0)
analyzed_count = status['inbox'].get('analyzed', {}).get('count', 0)
if pending_count > 10:
recommendations.append(f"⚠️ 待处理内容较多 ({pending_count} 项),建议优先处理")
if analyzed_count > 20:
recommendations.append(f"📦 已分析内容较多 ({analyzed_count} 项),建议归档")
if status['total_items'] == 0:
recommendations.append("📭 收件箱为空,暂无待处理内容")
return recommendations
# ==================== CLI Interface ====================
def run(self, trigger: str = "manual", scope: str = "all",
limit: int = None, report: bool = False):
"""
Main entry point for running the workflow
Args:
trigger: Trigger type (manual, scheduled)
scope: Processing scope
limit: Maximum items to process
report: Generate report after processing
"""
logger.info(f"KB Workflow started: trigger={trigger}, scope={scope}")
# Handle different triggers
if trigger == "status":
result = self.get_status()
elif trigger == "report":
result = self.generate_report()
elif trigger == "subscriptions":
result = self.check_subscriptions()
else:
# Process items
result = self.process_all(scope=scope, limit=limit)
# Generate report if requested
if report:
report_result = self.generate_report()
result['report'] = report_result
return result
def main():
"""CLI entry point"""
parser = argparse.ArgumentParser(
description='KB Workflow - Knowledge Base Workflow Orchestration',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Get workflow status
python kb_workflow.py --trigger status
# Process pending items manually
python kb_workflow.py --trigger manual --scope pending
# Process up to 5 items
python kb_workflow.py --trigger manual --limit 5
# Generate daily report
python kb_workflow.py --trigger scheduled --report
# Check subscriptions
python kb_workflow.py --trigger scheduled --scope subscriptions
"""
)
parser.add_argument(
'--trigger',
type=str,
default='manual',
choices=['manual', 'scheduled', 'webhook', 'status', 'report', 'subscriptions'],
help='Workflow trigger type (default: manual)'
)
parser.add_argument(
'--scope',
type=str,
default='all',
choices=['all', 'pending', 'metadata', 'content', 'analyzed', 'archive'],
help='Processing scope (default: all)'
)
parser.add_argument(
'--limit',
type=int,
default=None,
help='Maximum items to process'
)
parser.add_argument(
'--report',
action='store_true',
help='Generate report after processing'
)
parser.add_argument(
'--output',
type=str,
choices=['json', 'text'],
default='json',
help='Output format (default: json)'
)
parser.add_argument(
'--base-path',
type=str,
default=None,
help='Base path for knowledge base'
)
parser.add_argument(
'--verbose',
action='store_true',
help='Enable verbose logging'
)
args = parser.parse_args()
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
# Initialize workflow
workflow = KBWorkflow(base_path=args.base_path)
# Run workflow
result = workflow.run(
trigger=args.trigger,
scope=args.scope,
limit=args.limit,
report=args.report
)
# Output result
if args.output == 'json':
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
else:
print(result)
return 0 if result else 1
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,36 @@
{
"generated_at": "2026-02-10T12:13:35.989689",
"period": "daily",
"start_date": "2026-02-09T12:13:35.989689",
"summary": {
"total_items_inbox": 0,
"total_archived": 0,
"items_by_stage": {
"pending": {
"count": 0,
"path": "/home/admin/.openclaw/workspace/skills/kb-workflow/knowledge-base/pending"
},
"metadata": {
"count": 0,
"path": "/home/admin/.openclaw/workspace/skills/kb-workflow/knowledge-base/metadata"
},
"content": {
"count": 0,
"path": "/home/admin/.openclaw/workspace/skills/kb-workflow/knowledge-base/content"
},
"analyzed": {
"count": 0,
"path": "/home/admin/.openclaw/workspace/skills/kb-workflow/knowledge-base/analyzed"
}
}
},
"processing_stats": {
"pending": 0,
"metadata": 0,
"content": 0,
"analyzed": 0
},
"recommendations": [
"📭 收件箱为空,暂无待处理内容"
]
}
+44
View File
@@ -0,0 +1,44 @@
# KB Zhihu Metadata Skill
**kb-zhihu-metadata** - Extract Zhihu question/answer/user metadata.
## Features
- Question metadata extraction
- Answer metadata extraction
- User information extraction
- JSON output format
- Markdown conversion support
## Commands
```bash
# Question metadata
python zhihu_metadata.py --id "266309957" --type question
# Answer metadata
python zhihu_metadata.py --id "2003427891925509507" --type answer
```
## Output
```json
{
"success": true,
"data": {
"platform": "zhihu",
"type": "question",
"id": "266309957",
"title": "问题标题",
"author": "作者名字",
"answer_count": 50,
"followers": 1000
}
}
```
## Installation
```bash
cp -r kb-zhihu-metadata/ ~/skills/
```
@@ -0,0 +1,17 @@
from markdownify import MarkdownConverter as _MarkdownConverter
class MarkdownConverter:
"""HTML 转 Markdown 转换器,保留完整格式"""
def __init__(self):
self.converter = _MarkdownConverter(
heading_style="ATX",
bullets="*,-",
code_blocks=True,
tables=True,
strip=["script", "style", "nav", "footer", "aside"]
)
def convert(self, html: str) -> str:
"""转换 HTML 为 Markdown"""
return self.converter.convert(html)
+385
View File
@@ -0,0 +1,385 @@
"""
知乎 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 = {
'answer': r'zhihu\.com/question/\d+/answer/(\d+)',
'article': r'zhuanlan\.zhihu\.com/p/(\d+)',
'question': r'zhihu\.com/question/(\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_answer_content(self) -> Dict[str, Any]:
"""提取回答内容"""
result = {
'type': 'answer',
'title': '',
'author': '',
'content': '',
'content_markdown': '',
'publish_time': '',
'vote_up': 0,
}
# 打印页面标题用于调试
page_title = self.browser.page.title()
print(f"页面标题: {page_title}")
# 尝试保存页面快照用于调试
try:
import os
debug_dir = '/home/admin/.openclaw/workspace/debug'
os.makedirs(debug_dir, exist_ok=True)
self.browser.page.screenshot(path=os.path.join(debug_dir, 'zhihu_screenshot.png'))
with open(os.path.join(debug_dir, 'zhihu_page.html'), 'w', encoding='utf-8') as f:
f.write(self.browser.page.content())
print(f"页面快照已保存到 {debug_dir}")
except Exception as e:
print(f"保存页面快照时出错: {e}")
# 尝试多个可能的选择器
selectors = {
'title': ['h1.QuestionHeader-title', 'h1', '.QuestionHeader-main'],
'author': ['.AuthorInfo-name', '.AuthorInfo .Text', '[itemprop="name"]'],
'content': ['.RichContent-inner', '.RichContent', '.List-itemText', '[data-zop-feedlist] article'],
'time': ['.ContentItem-time', '[itemprop="dateCreated"]'],
'vote': ['.VoteButton--up .VoteButton-label', '.VoteButton--up']
}
for selector in selectors['title']:
title_elem = self.browser.query_selector(selector)
if title_elem:
result['title'] = self._extract_text_content(title_elem)
print(f"找到标题(使用选择器 {selector}): {result['title'][:50]}...")
break
for selector in selectors['author']:
author_elem = self.browser.query_selector(selector)
if author_elem:
result['author'] = self._extract_text_content(author_elem)
print(f"找到作者(使用选择器 {selector}): {result['author']}")
break
for selector in selectors['content']:
content_elem = self.browser.query_selector(selector)
if content_elem:
html_content = self._extract_html_content(content_elem)
result['content'] = html_content
result['content_markdown'] = self.markdown_converter.convert(html_content)
print(f"找到内容(使用选择器 {selector}),长度: {len(result['content_markdown'])}")
break
for selector in selectors['time']:
time_elem = self.browser.query_selector(selector)
if time_elem:
result['publish_time'] = time_elem.get_attribute('data-tooltip') or self._extract_text_content(time_elem)
break
for selector in selectors['vote']:
vote_elem = self.browser.query_selector(selector)
if vote_elem:
vote_text = self._extract_text_content(vote_elem)
try:
result['vote_up'] = int(vote_text.replace(',', ''))
except (ValueError, AttributeError):
pass
break
return result
def _extract_article_content(self) -> Dict[str, Any]:
"""提取文章内容"""
result = {
'type': 'article',
'title': '',
'author': '',
'content': '',
'content_markdown': '',
'publish_time': '',
'vote_up': 0,
}
# 提取文章标题
title_elem = self.browser.query_selector('.Post-Title')
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_question_content(self) -> Dict[str, Any]:
"""提取问题内容(问题页本身)"""
result = {
'type': 'question',
'title': '',
'author': '',
'content': '',
'content_markdown': '',
'publish_time': '',
'vote_up': 0,
}
# 提取问题标题
title_elem = self.browser.query_selector('h1.QuestionHeader-title')
if title_elem:
result['title'] = self._extract_text_content(title_elem)
# 提取问题详情
detail_elem = self.browser.query_selector('.QuestionHeader-detail')
if detail_elem:
html_content = self._extract_html_content(detail_elem)
result['content'] = html_content
result['content_markdown'] = self.markdown_converter.convert(html_content)
# 问题的作者通常是提问者
author_elem = self.browser.query_selector('.AuthorInfo-name')
if author_elem:
result['author'] = self._extract_text_content(author_elem)
return result
def extract(self, url: str) -> Optional[Dict[str, Any]]:
"""
从 URL 提取知乎内容
Args:
url: 知乎 URL
Returns:
包含提取内容的字典,结构如下:
{
'type': 'answer' | 'article' | 'question',
'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 == 'answer':
selectors = ['.RichContent-inner', '.RichContent', '.List-itemText', '[data-zop-feedlist]']
print(f"尝试查找选择器: {selectors}")
result = self._extract_answer_content()
elif url_type == 'article':
selectors = ['.Post-RichText', '.Post-Title']
print(f"尝试查找选择器: {selectors}")
result = self._extract_article_content()
else: # question
selectors = ['.QuestionHeader-title', 'h1']
print(f"尝试查找选择器: {selectors}")
result = self._extract_question_content()
# 添加额外信息
result['url'] = url
result['content_id'] = self.extract_id(url)
print(f"提取成功! 标题: {result['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)
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env python3
"""
知乎元数据获取工具
支持获取知乎问题、回答、用户的元数据信息。
"""
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_question(question_id: str, headless: bool = True) -> Dict[str, Any]:
"""
获取知乎问题元数据
Args:
question_id: 问题 ID (数字字符串)
headless: 是否使用无头浏览器
Returns:
问题元数据字典
"""
url = f"https://www.zhihu.com/question/{question_id}"
return _extract_metadata(url, "question", headless)
def extract_answer(answer_id: str, headless: bool = True) -> Dict[str, Any]:
"""
获取知乎回答元数据
Args:
answer_id: 回答 ID (数字字符串)
headless: 是否使用无头浏览器
Returns:
回答元数据字典
"""
# 回答 URL 格式: https://www.zhihu.com/question/{question_id}/answer/{answer_id}
# 需要先获取问题ID,这里简化处理,尝试从回答ID构建URL
# 实际上知乎回答URL需要问题ID,这里用模拟URL
url = f"https://www.zhihu.com/question/0/answer/{answer_id}"
return _extract_metadata(url, "answer", headless)
def extract_user(url_token: str, headless: bool = True) -> Dict[str, Any]:
"""
获取知乎用户信息
Args:
url_token: 用户 URL token
headless: 是否使用无头浏览器
Returns:
用户信息字典
"""
url = f"https://www.zhihu.com/people/{url_token}"
return _extract_user_metadata(url, headless)
def _extract_metadata(url: str, content_type: str, headless: bool) -> Dict[str, Any]:
"""
内部函数:提取元数据
Args:
url: 知乎 URL
content_type: 内容类型 (question/answer)
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 _extract_user_metadata(url: str, headless: bool) -> Dict[str, Any]:
"""
内部函数:提取用户信息
Args:
url: 用户主页 URL
headless: 是否使用无头浏览器
Returns:
用户信息字典
"""
extractor = ZhihuPlaywrightExtractor(headless=headless)
try:
extractor.browser.start()
extractor.browser.goto(url, wait_until="networkidle")
import time
time.sleep(3)
# 提取用户名
username = ""
username_elem = extractor.browser.query_selector('.ProfileHeader-name')
if username_elem:
username = username_elem.inner_text()
# 提取个人简介
headline = ""
headline_elem = extractor.browser.query_selector('.ProfileHeader-headline')
if headline_elem:
headline = headline_elem.inner_text()
# 提取简介详情
description = ""
desc_elem = extractor.browser.query_selector('.ProfileHeader-description')
if desc_elem:
description = desc_elem.inner_text()
# 提取关注者数
followers = 0
follower_elem = extractor.browser.query_selector('.FollowshipCard-followersNum')
if follower_elem:
follower_text = follower_elem.inner_text()
try:
followers = int(follower_text.replace(',', ''))
except ValueError:
pass
# 提取关注者数
following = 0
following_elem = extractor.browser.query_selector('.FollowshipCard-followingsNum')
if following_elem:
following_text = following_elem.inner_text()
try:
following = int(following_text.replace(',', ''))
except ValueError:
pass
# 提取回答数
answers = 0
answers_elem = extractor.browser.query_selector('.Profile-mainColumn .Tabs-item span')
if answers_elem:
answers_text = answers_elem.inner_text()
try:
answers = int(answers_text.replace(',', ''))
except ValueError:
pass
metadata = {
"type": "user",
"url": url,
"username": username,
"headline": headline,
"description": description,
"followers": followers,
"following": following,
"answers_count": answers,
}
return metadata
except Exception as e:
return {
"type": "user",
"error": str(e),
"url": url
}
finally:
extractor.browser.close()
def main():
"""命令行入口"""
parser = argparse.ArgumentParser(
description="知乎元数据获取工具",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python zhihu_metadata.py --id "266309957" --type question
python zhihu_metadata.py --id "2003427891925509507" --type answer
python zhihu_metadata.py --id "example-user" --type user
"""
)
parser.add_argument(
"--id", "-i",
required=True,
help="知乎内容 ID (问题ID、回答ID或用户URL token)"
)
parser.add_argument(
"--type", "-t",
required=True,
choices=["question", "answer", "user"],
help="内容类型"
)
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 == "question":
result = extract_question(args.id, headless=args.headless)
elif args.type == "answer":
result = extract_answer(args.id, headless=args.headless)
else: # user
result = extract_user(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()
@@ -0,0 +1,168 @@
# 知识库维护系统 - 整体流程图
## 1. 用户交互流程(接收链接)
```mermaid
flowchart TD
A[用户发送链接] --> B{技能1: 链接解析}
B --> C{类型判断}
C -->|单个内容| D[直接解析]
C -->|列表类型| E[询问用户]
E --> F[处理范围选择]
F --> G[订阅选项选择]
G --> H[写入订阅配置]
D --> I[写入收件箱 pending/]
H --> I
I --> J[返回用户: 已收录]
```
## 2. 定时任务/手动触发流程
```mermaid
flowchart TD
A[定时任务触发<br/>或手动触发] --> B[技能5: 工作流编排]
B --> C[扫描收件箱]
C --> D{pending/ 目录}
D -->|有待处理| E[获取待处理列表]
D -->|无| F[检查订阅更新]
E --> G[技能2: 获取元数据]
G --> H[更新状态: metadata_ready]
H --> I[技能3: 获取内容]
I --> J[更新状态: content_ready]
J --> K[技能4: 深度分析]
K --> L[归档到 archive/]
L --> M[更新订阅追踪]
M --> N[继续处理下一个]
F --> O{有订阅?}
O -->|是| P[检测更新]
P --> Q[新增内容加入 pending/]
Q --> R[更新订阅时间]
O -->|否| S[跳过]
N --> T{还有待处理?}
T -->|是| E
T -->|否| U[生成汇报文件]
U --> V[完成]
```
## 3. 完整主流程(简化版)
```mermaid
flowchart TD
subgraph 用户交互
A[用户发送链接] --> B[技能1: link-parser]
B --> C{内容类型}
C -->|单个| D[直接处理]
C -->|列表| E[询问用户选项]
E --> F[保存订阅配置]
D --> G[写入收件箱 pending/]
F --> G
end
subgraph 定时处理
H[定时/手动触发] --> I[技能5: kb-workflow]
I --> J[扫描收件箱]
J --> K[技能2: metadata-fetcher]
K --> L[技能3: content-fetcher]
L --> M[技能4: deep-analyzer]
M --> N[归档]
N --> O[更新订阅]
O --> P[生成汇报]
end
G --> H
style A fill:#e1f5fe
style I fill:#fff3e0
style K fill:#f3e5f5
style L fill:#e8f5e9
style M fill:#fce4ec
```
## 4. 各平台技能调用关系
```mermaid
flowchart LR
subgraph 技能1
LP[link-parser]
end
subgraph 技能2[元数据获取]
BM[bilibili-metadata]
YM[ youtube-metadata]
ZM[ zhihu-metadata]
end
subgraph 技能3[内容获取]
BC[bilibili-content]
OC[ocr-processor]
end
subgraph 技能4
DA[deep-analyzer]
end
LP -->|解析结果| BM
LP -->|解析结果| YM
LP -->|解析结果| ZM
BM -->|元数据| BC
BC -->|低分视频| OC
OC -->|OCR结果| DA
BM -->|元数据| DA
```
## 5. 数据流转
```mermaid
flowchart LR
subgraph 输入
URL[用户链接]
end
subgraph 收件箱
P[pending/]
M[metadata/]
C[content/]
end
subgraph 输出
A[archive/YYYY/MM/]
R[report.json]
end
URL -->|技能1| P
P -->|技能2| M
M -->|技能3| C
C -->|技能4| A
A -.->|统计| R
style P fill:#fffde7
style M fill:#e3f2fd
style C fill:#e8f5e9
style A fill:#f3e5f5
```
## 6. 状态流转
```mermaid
stateDiagram-v2
[*] --> pending: 链接收录
pending --> metadata: 技能2完成
metadata --> content: 技能3完成
content --> analyzed: 技能4完成
analyzed --> archived: 归档完成
archived --> [*]
pending --> skipped: 跳过
metadata --> failed: 元数据获取失败
content --> failed: 内容获取失败
analyzed --> failed: 分析失败
failed --> retry: 重试
retry --> pending
retry --> metadata
retry --> content
```
File diff suppressed because it is too large Load Diff