refactor: 删除多余的历史文件,保留核心kb_workflow.py

This commit is contained in:
OpenClaw Agent
2026-02-11 00:34:30 +08:00
parent 5658529a84
commit ce92b3b3fb
41 changed files with 1 additions and 9366 deletions
+1 -30
View File
@@ -246,7 +246,7 @@ def main():
parser.add_argument('--archive', type=str, help='归档的文件路径')
parser.add_argument('--base-path', type=str, help='获取知识库根目录')
parser.add_argument('--pending', action='store_true', help='获取url解析目录')
parser.add_argument('--pending', action='store_true', help='url解析目录')
parser.add_argument('--metadata', action='store_true', help='获取元数据目录')
parser.add_argument('--content', action='store_true', help='获取内容目录')
parser.add_argument('--content_downloads', action='store_true', help='获取主内容下载目录')
@@ -259,35 +259,6 @@ def main():
base_path = Path(args.base_path) if args.base_path else None
# 获取目录参数处理
dirs = ensure_directories(base_path)
if args.extract_platform:
platform = extract_platform(args.extract_platform)
print(json.dumps({"platform": platform}, ensure_ascii=False, indent=2))
return
# 目录路径查询
dir_map = {
"pending": dirs.get("pending"),
"metadata": dirs.get("metadata"),
"content": dirs.get("content"),
"content_downloads": dirs.get("content_downloads"),
"content_subtitles": dirs.get("content_subtitles"),
"analyzed": dirs.get("analyzed"),
"archive": dirs.get("archive"),
"archive_subscriptions": dirs.get("archive_subscriptions"),
}
found = False
for arg_name, dir_path in dir_map.items():
if getattr(args, arg_name, False) and dir_path:
print(json.dumps({"path": str(dir_path)}, ensure_ascii=False, indent=2))
found = True
if found:
return
if args.check and args.url:
result = check_duplicate(base_path, args.url)
print(json.dumps(result, ensure_ascii=False, indent=2))
@@ -1,36 +0,0 @@
{
"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": [
"📭 收件箱为空,暂无待处理内容"
]
}
-96
View File
@@ -1,96 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
KB Workflow Skill Main Entry
当用户发送"添加到知识库 + 链接"时触发。
此入口只负责:
1. 确认触发条件
2. 引导主代理读取 SKILL.md
3. 让主代理按照 SKILL.md 的指导执行完整 workflow
"""
import re
import sys
# 触发词模式
TRIGGER_PATTERNS = [
r'添加到知识库\s+(https?://[^\s]+)',
r'保存到知识库\s+(https?://[^\s]+)',
]
def extract_link(message: str) -> str | None:
"""从消息中提取链接"""
for pattern in TRIGGER_PATTERNS:
match = re.search(pattern, message)
if match:
return match.group(1)
return None
def main(message: str) -> Dict[str, Any]:
"""
主入口函数
此函数返回指导信息,让主代理知道如何执行 workflow。
实际的 workflow 逻辑在 SKILL.md 中定义。
"""
result = {
"success": False,
"url": None,
"instructions": None,
"error": None
}
try:
# 1. 提取链接
url = extract_link(message)
if not url:
result["error"] = "未找到链接"
return result
result["url"] = url
# 2. 生成指导信息
result["success"] = True
result["instructions"] = {
"action": "execute_workflow",
"url": url,
"workflow_prompt": f"""请按照 SKILL.md 中的指导执行完整的知识库 workflow:
1. 查重 - 检查知识库是否已存在此内容
2. 调用 kb-link-parser 解析 URL
3. 根据平台调用对应元数据技能
4. 调用内容下载技能
5. 调用 OCR 处理(30分钟超时)
6. 调用深度分析
7. 归档到 archive/ 目录
8. Git commit
9. 汇报完成
当前处理的 URL: {url}
开始执行 workflow。"""
}
except Exception as e:
result["error"] = str(e)
return result
if __name__ == "__main__":
# 从参数获取消息
if len(sys.argv) > 1:
message = sys.argv[1]
else:
message = sys.stdin.read()
if not message:
print("请提供消息内容")
sys.exit(1)
result = main(message)
print(result)
-36
View File
@@ -1,36 +0,0 @@
"""Obsidian KB - 知识库内容管理技能
Checkpoint 4-5: 内容处理管道与长视频分段分析
"""
from .models import Content, ContentType, ContentStatus, TimelineEntry, AnalysisResult
from .light_scanner import LightScanner
from .deep_analyzer import DeepAnalyzer
from .pipeline import ProcessingPipeline
from .content_chunker import ContentChunker, Chunk, get_chunking_strategy, chunk_content
from .ai_client import AIClient
from .config import Config, get_config
__all__ = [
# 模型
'Content',
'ContentType',
'ContentStatus',
'TimelineEntry',
'AnalysisResult',
# 处理器
'LightScanner',
'DeepAnalyzer',
'ProcessingPipeline',
# 分片
'ContentChunker',
'Chunk',
'get_chunking_strategy',
'chunk_content',
# AI客户端
'AIClient',
# 配置
'Config',
'get_config',
]
__version__ = '0.2.0'
-514
View File
@@ -1,514 +0,0 @@
"""Obsidian KB - AI客户端
支持多个 AI 提供商和 fallback 机制
"""
import os
import json
import requests
from typing import Dict, Any, List, Optional, Tuple
from src.models import ContentType
from src.config import get_config
from src.content_chunker import ContentChunker, get_chunking_strategy, chunk_content
class AIClient:
"""
AI客户端 - 支持分段分析长视频内容和 fallback 机制
"""
def __init__(self):
self.config = get_config()
self.chunker = ContentChunker()
# 获取 AI 配置
self._load_ai_config()
def _load_ai_config(self) -> None:
"""加载 AI 配置(主 provider + fallbacks)"""
self.ai_configs = []
# 加载主配置
main_provider = self.config.get('ai.provider', 'openai')
main_config = {
'provider': main_provider,
'api_key': self.config.get('ai.api_key'),
'model': self.config.get('ai.model', 'gpt-4'),
'base_url': self.config.get('ai.base_url'),
'is_primary': True
}
self.ai_configs.append(main_config)
# 加载 fallback 配置
fallbacks = self.config.get('ai.fallbacks', [])
for fb in fallbacks:
self.ai_configs.append({
'provider': fb.get('provider'),
'api_key': fb.get('api_key'),
'model': fb.get('model'),
'base_url': fb.get('base_url'),
'is_primary': False
})
def _call_api_with_fallback(self, prompt: str, max_tokens: int = 1000, response_format: str = 'text') -> Tuple[bool, str]:
"""
使用 fallback 机制调用 API
Args:
prompt: 提示词
max_tokens: 最大 token 数
response_format: 响应格式 ('text' 或 'json')
Returns:
(success, response) 元组
"""
last_error = None
for i, ai_config in enumerate(self.ai_configs):
try:
if not ai_config.get('api_key'):
print(f"[AIClient] 跳过 {ai_config.get('provider')} - API Key 为空")
continue
provider_type = ai_config.get('provider')
print(f"[AIClient] 尝试使用 {provider_type} (优先级 {i+1}/{len(self.ai_configs)})...")
response = self._call_single_provider(ai_config, prompt, max_tokens, response_format)
return True, response
except Exception as e:
last_error = e
print(f"[AIClient] {provider_type} 调用失败: {e}")
continue
# 所有 provider 都失败了
error_msg = f"所有 AI 提供商均失败,最后错误: {last_error}" if last_error else "没有可用的 AI 配置"
print(f"[AIClient] {error_msg}")
return False, self._mock_generate(prompt, max_tokens) # 返回 mock 响应作为兜底
def _call_single_provider(self, ai_config: Dict, prompt: str, max_tokens: int, response_format: str) -> str:
"""
调用单个 AI 提供商
Args:
ai_config: AI 配置
prompt: 提示词
max_tokens: 最大 token 数
response_format: 响应格式
Returns:
API 响应文本
"""
provider = ai_config.get('provider')
api_key = ai_config.get('api_key')
model = ai_config.get('model')
base_url = ai_config.get('base_url')
if provider == 'minimax':
return self._call_minimax(api_key, model, base_url, prompt, max_tokens, response_format)
elif provider == 'zhipu':
return self._call_zhipu(api_key, model, base_url, prompt, max_tokens, response_format)
elif provider == 'alibaba':
return self._call_alibaba(api_key, model, base_url, prompt, max_tokens, response_format)
elif provider == 'openai':
return self._call_openai(api_key, model, base_url, prompt, max_tokens, response_format)
else:
raise ValueError(f"不支持的 provider: {provider}")
def _call_minimax(self, api_key: str, model: str, base_url: str, prompt: str, max_tokens: int, response_format: str) -> str:
"""调用 MiniMax API (Anthropic Messages 兼容接口)"""
url = base_url or "https://api.minimaxi.com/anthropic/v1/messages"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
payload = {
"model": model,
"max_tokens": max_tokens,
"messages": [
{"role": "user", "content": prompt}
]
}
if response_format == 'json':
payload['messages'][0]['content'] = prompt + "\n\n请直接返回 JSON 格式,不要有其他文字。"
response = requests.post(url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
data = response.json()
# MiniMax 返回的是 Anthropic 格式,content 是一个数组
# 需要找到 type='text' 的元素
for item in data.get('content', []):
if item.get('type') == 'text':
return item.get('text', '')
# 如果没有找到 text 类型,返回第一个元素的值(兼容旧格式)
return data.get('content', [{}])[0].get('text', '')
def _call_zhipu(self, api_key: str, model: str, base_url: str, prompt: str, max_tokens: int, response_format: str) -> str:
"""调用智谱 AI API (OpenAI 兼容接口)"""
# 确保 base_url 包含 /chat/completions
if base_url and not base_url.endswith('/chat/completions'):
url = f"{base_url}/chat/completions"
else:
url = base_url or "https://open.bigmodel.cn/api/paas/v4/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
payload = {
"model": model,
"max_tokens": max_tokens,
"messages": [
{"role": "user", "content": prompt}
]
}
response = requests.post(url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
data = response.json()
return data['choices'][0]['message']['content']
def _call_alibaba(self, api_key: str, model: str, base_url: str, prompt: str, max_tokens: int, response_format: str) -> str:
"""调用阿里云通义千问 API (OpenAI 兼容接口)"""
url = f"{base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
payload = {
"model": model,
"max_tokens": max_tokens,
"messages": [
{"role": "user", "content": prompt}
]
}
response = requests.post(url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
data = response.json()
return data['choices'][0]['message']['content']
def _call_openai(self, api_key: str, model: str, base_url: str, prompt: str, max_tokens: int, response_format: str) -> str:
"""调用 OpenAI API"""
url = base_url or "https://api.openai.com/v1/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
payload = {
"model": model,
"max_tokens": max_tokens,
"messages": [
{"role": "user", "content": prompt}
]
}
response = requests.post(url, headers=headers, json=payload, timeout=30)
response.raise_for_status()
data = response.json()
return data['choices'][0]['message']['content']
def quick_generate(self, prompt: str, max_tokens: int = 100) -> str:
"""快速生成(用于轻量扫描)"""
success, response = self._call_api_with_fallback(prompt, max_tokens, 'text')
return response
def generate_json(self, prompt: str) -> Dict[str, Any]:
"""生成JSON格式响应"""
success, response = self._call_api_with_fallback(prompt, 1000, 'json')
try:
return json.loads(response)
except:
return self._parse_json_from_text(response)
def _deep_analyze(self, content_text: str, content_type: str, duration: Optional[int] = None) -> Dict[str, Any]:
"""
C5.3: 深度分析入口,根据内容时长决定是否分段分析
Args:
content_text: 内容文本
content_type: 内容类型
duration: 视频时长(秒)
Returns:
分析结果字典
"""
duration = duration or 0
print(f"[AIClient] 深度分析: type={content_type}, duration={duration}s")
# 长视频(>=30分钟)且是视频类型,使用分段分析
if duration >= 1800 and content_type == "video":
print(f"[AIClient] 使用分段分析策略")
return self._analyze_in_chunks(content_text, duration)
# 短视频直接分析
print(f"[AIClient] 使用直接分析策略")
return self._analyze_direct(content_text)
def _analyze_in_chunks(self, content_text: str, duration: int) -> Dict[str, Any]:
"""
C5.3: 分段分析长视频
Args:
content_text: SRT字幕文本
duration: 视频时长(秒)
Returns:
合并后的分析结果
"""
# 1. 获取分段策略
strategy = get_chunking_strategy(duration)
print(f"[AIClient] 分段策略: {strategy['description']}")
# 2. 执行分片
chunks = chunk_content(content_text, duration)
print(f"[AIClient] 内容已分为 {len(chunks)} 段")
# 3. 逐段分析
chunk_summaries = []
for i, chunk in enumerate(chunks):
print(f"[AIClient] 分析第 {i+1}/{len(chunks)} 段...")
summary = self._analyze_chunk(chunk.text, chunk.index)
# 处理时间格式(可能是float秒数或字符串)
start_time = chunk.start_time
end_time = chunk.end_time
if isinstance(start_time, (int, float)):
start_time = self._format_time(start_time)
if isinstance(end_time, (int, float)):
end_time = self._format_time(end_time)
chunk_summaries.append({
'index': chunk.index,
'start_time': start_time,
'end_time': end_time,
'summary': summary
})
# 4. 合并所有段摘要
print(f"[AIClient] 合并 {len(chunk_summaries)} 段分析结果")
final_result = self._merge_summaries(chunk_summaries, "video")
# 5. 构建完整时间轴
final_result['timeline'] = self._build_timeline_from_chunks(chunk_summaries)
return final_result
def _analyze_chunk(self, chunk_text: str, chunk_index: int) -> str:
"""
C5.3: 分析单个内容分片
Args:
chunk_text: 分片文本
chunk_index: 分片索引
Returns:
分片摘要(限制500字以内)
"""
# 截断文本避免过长
text_sample = chunk_text[:3000] if len(chunk_text) > 3000 else chunk_text
prompt = f"""请对以下视频片段进行简要分析(限制在300字以内):
片段内容:
{text_sample}
请提供:
1. 这段的核心主题(50字以内)
2. 关键要点(3-5条)
3. 重要细节或引用
请用中文回答,控制在300字以内。"""
try:
summary = self.quick_generate(prompt, max_tokens=500)
# 确保不超过500字
if len(summary) > 500:
summary = summary[:497] + "..."
return summary
except Exception as e:
print(f"[AIClient] 分片 {chunk_index} 分析失败: {e}")
return f"第{chunk_index+1}段分析失败"
def _merge_summaries(self, chunk_summaries: List[Dict], content_type: str) -> Dict[str, Any]:
"""
C5.3: 合并所有段摘要,生成完整分析报告
Args:
chunk_summaries: 各段摘要列表
content_type: 内容类型
Returns:
完整分析结果
"""
# 构建合并提示
summaries_text = "\n\n".join([
f"=== 第{s['index']+1}段 ({s['start_time']}-{s['end_time']}) ===\n{s['summary']}"
for s in chunk_summaries
])
prompt = f"""基于以下各段分析,生成完整的视频分析报告:
{summaries_text}
请提供以下格式的JSON:
{{
"summary": "整体摘要(200字以内)",
"topics": ["主题1", "主题2", "主题3", "主题4"],
"insights": ["深入见解1", "深入见解2", "深入见解3"],
"actions": ["建议行动1", "建议行动2"],
"full_analysis": "详细分析(500字以内)"
}}"""
try:
result = self.generate_json(prompt)
# 确保必要字段存在
if 'topics' not in result:
result['topics'] = ["主题A", "主题B", "主题C"]
if 'insights' not in result:
result['insights'] = ["重要见解1", "重要见解2"]
if 'actions' not in result:
result['actions'] = []
if 'summary' not in result:
result['summary'] = "长视频分析完成"
if 'full_analysis' not in result:
result['full_analysis'] = summaries_text[:500]
return result
except Exception as e:
print(f"[AIClient] 合并摘要失败: {e}")
# 返回基础合并结果
return {
'summary': f'视频共分为{len(chunk_summaries)}个部分进行分析',
'topics': [f"主题{i+1}" for i in range(min(4, len(chunk_summaries)))],
'insights': ['从分段分析中综合得出'],
'actions': [],
'full_analysis': summaries_text[:800]
}
def _analyze_direct(self, content_text: str) -> Dict[str, Any]:
"""直接分析(不分段)"""
# 截断文本
text_sample = content_text[:5000] if len(content_text) > 5000 else content_text
prompt = f"""请分析以下内容,提供JSON格式结果:
{text_sample}
返回格式:
{{
"summary": "整体摘要(150字以内)",
"topics": ["主题1", "主题2", "主题3"],
"insights": ["见解1", "见解2"],
"actions": ["行动建议1"],
"timeline": [
{{"start_time": "00:00", "end_time": "05:00", "title": "第一部分", "summary": "..."}}
]
}}"""
try:
return self.generate_json(prompt)
except Exception as e:
print(f"[AIClient] 直接分析失败: {e}")
return {
'summary': '分析失败',
'topics': ['无法解析'],
'insights': ['请检查内容格式'],
'actions': [],
'timeline': []
}
def _format_time(self, seconds: float) -> str:
"""格式化秒数为时间字符串"""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
if hours > 0:
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
return f"{minutes:02d}:{secs:02d}"
def _build_timeline_from_chunks(self, chunk_summaries: List[Dict]) -> List[Dict[str, Any]]:
"""从分片摘要构建时间轴"""
timeline = []
for summary in chunk_summaries:
timeline.append({
'index': summary['index'],
'start_time': summary['start_time'],
'end_time': summary['end_time'],
'title': f"第{summary['index']+1}部分",
'summary': summary['summary'][:200] + "..." if len(summary['summary']) > 200 else summary['summary']
})
return timeline
def _mock_generate(self, prompt: str, max_tokens: int) -> str:
"""模拟AI生成(实际实现应调用真实API)"""
# 根据prompt内容返回模拟响应
prompt_lower = prompt.lower()
if 'json' in prompt_lower or 'json格式' in prompt:
if '主题' in prompt or 'topic' in prompt_lower:
return json.dumps({
'summary': '这是一个关于人工智能和技术发展的内容,涵盖了多个重要主题。',
'topics': ['人工智能', '机器学习', '未来发展', '技术趋势'],
'insights': [
'AI正在改变我们的工作方式',
'机器学习应用越来越广泛',
'未来技术发展趋势值得关注'
],
'actions': ['深入研究相关技术', '关注行业动态'],
'full_analysis': '详细分析内容...'
}, ensure_ascii=False)
return json.dumps({'result': '模拟JSON响应'})
if '摘要' in prompt or 'summary' in prompt_lower:
return '这是一段关于人工智能发展的重要讨论,涵盖了技术趋势和未来应用。'
if '关键词' in prompt or 'keyword' in prompt_lower:
return '人工智能, 机器学习, 深度学习, 神经网络, 自然语言处理'
return '模拟AI响应内容'
def _parse_json_from_text(self, text: str) -> Dict[str, Any]:
"""从文本中解析JSON"""
# 尝试提取JSON块
import re
json_match = re.search(r'\{[\s\S]*\}', text)
if json_match:
try:
return json.loads(json_match.group())
except:
pass
return {'raw_response': text}
class MultiAIClient:
"""
多AI客户端 - 支持多种AI提供商的客户端
"""
def __init__(self):
self.config = get_config()
self.primary_client = AIClient()
self.clients = {
'openai': self.primary_client,
'local': AIClient(), # 可以配置为本地模型
}
def _deep_analyze(self, content_text: str, content_type: str, duration: Optional[int] = None) -> Dict[str, Any]:
"""使用主客户端进行深度分析"""
return self.primary_client._deep_analyze(content_text, content_type, duration)
def quick_generate(self, prompt: str, max_tokens: int = 100, provider: str = None) -> str:
"""快速生成"""
client = self.clients.get(provider, self.primary_client)
return client.quick_generate(prompt, max_tokens)
-569
View File
@@ -1,569 +0,0 @@
#!/usr/bin/env python3
"""Obsidian KB Knowledge Base CLI Tool
Command line interface for managing Obsidian knowledge base content.
"""
import argparse
import sys
import os
from typing import Optional
from datetime import datetime
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from src.config import Config
from src.storage import Storage
from src.pipeline import ProcessingPipeline
from src.models import ContentStatus
CONFIG_AVAILABLE = True
except ImportError as e:
print(f"Import warning: {e}", file=sys.stderr)
CONFIG_AVAILABLE = False
def print_header(text: str):
"""Print formatted header."""
print(f"\n{'=' * 50}")
print(f" {text}")
print(f"{'=' * 50}\n")
def print_table(headers: list, rows: list, max_width: int = 80):
"""Print a simple formatted table."""
if not rows:
print("(No data)")
return
# Calculate column widths
col_widths = [len(h) for h in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = max(col_widths[i], len(str(cell)[:max_width]))
# Print headers
header_line = " | ".join(h.ljust(w) for h, w in zip(headers, col_widths))
print(header_line)
print("-" * len(header_line))
# Print rows
for row in rows:
row_str = " | ".join(str(cell)[:max_width].ljust(w) for cell, w in zip(row, col_widths))
print(row_str)
print()
def cmd_subscribe(args: argparse.Namespace):
"""Handle subscribe command - save subscription configuration."""
print_header("Subscribe Configuration")
if not CONFIG_AVAILABLE:
print("Error: Config module not available. Please check installation.")
return 1
config = Config()
# Build subscription entry
subscription = {
'platform': args.platform,
'type': args.type,
'id': args.id,
'name': args.name or f"{args.platform}_{args.type}_{args.id}",
'created_at': datetime.now().isoformat()
}
# Load existing subscriptions
subscriptions = config.get('subscriptions', [])
# Check for duplicates
for sub in subscriptions:
if sub['platform'] == args.platform and sub['id'] == args.id:
print(f"⚠️ Subscription already exists: {sub['name']}")
print(" Updating configuration...")
sub.update(subscription)
break
else:
subscriptions.append(subscription)
print(f"✅ Added new subscription: {subscription['name']}")
# Save configuration
config.set('subscriptions', subscriptions)
config.save()
print(f"\n Platform: {args.platform}")
print(f" Type: {args.type}")
print(f" ID: {args.id}")
print(f" Name: {subscription['name']}")
print(f"\n💾 Configuration saved to config.yaml")
return 0
def cmd_inbox(args: argparse.Namespace):
"""Handle inbox command - display pending items."""
print_header("Inbox Contents")
if not CONFIG_AVAILABLE:
print("Error: Storage module not available.")
return 1
storage = Storage()
# Filter for CONFIRMED status (ready to process)
contents = storage.list_inbox(status=ContentStatus.CONFIRMED)
if not contents:
print("📭 Inbox is empty!")
return 0
# Sort by the requested criteria
if args.sort == 'score':
contents.sort(key=lambda x: x.quality_metrics.overall_score if hasattr(x, 'quality_metrics') else 0, reverse=True)
else: # date
contents.sort(key=lambda x: x.created_at, reverse=True)
# Limit results
contents = contents[:args.limit]
print(f"Showing {len(contents)} item(s) (sorted by {args.sort}):\n")
# Format table rows
rows = []
for idx, content in enumerate(contents, 1):
title = content.title[:40] if content.title else 'Untitled'
score = content.quality_metrics.overall_score if hasattr(content, 'quality_metrics') else 0
summary = content.summary[:50] if content.summary else 'No summary'
content_id = content.id
rows.append([
f"{idx:2d}",
content_id[:12],
f"{score:.1f}",
title,
summary + "..." if len(content.summary or '') > 50 else summary
])
print_table(
["#", "ID", "Score", "Title", "Summary"],
rows
)
print(f"\n💡 Use 'process <id>' to process an item, or 'process all' to process all")
return 0
def cmd_process(args: argparse.Namespace):
"""Handle process command - process content items."""
print_header("Process Content")
if not CONFIG_AVAILABLE:
print("Error: Pipeline module not available.")
return 1
storage = Storage()
# Import pipeline components
try:
from src.pipeline import ProcessingPipeline
from src.light_scanner import LightScanner
from src.deep_analyzer import DeepAnalyzer
from src.config import get_config
config = get_config()
pipeline = ProcessingPipeline()
except Exception as e:
print(f"❌ Error initializing pipeline: {e}")
return 1
# Determine what to process
if args.content_id == 'all':
items = storage.list_inbox(status=ContentStatus.CONFIRMED)
if not items:
print("📭 Inbox is empty! Nothing to process.")
return 0
print(f"Found {len(items)} item(s) to process.\n")
if not args.yes:
confirm = input("Process all items? [Y/n]: ").strip().lower()
if confirm and confirm not in ('y', 'yes'):
print("❌ Cancelled.")
return 0
else:
item = storage.load_content(args.content_id)
if not item:
print(f"❌ Content not found: {args.content_id}")
return 1
items = [item]
print(f"Processing single item: {item.title}\n")
# Process items with progress
success_count = 0
fail_count = 0
for i, item in enumerate(items, 1):
content_id = item.id
title = item.title or 'Untitled'
print(f"[{i}/{len(items)}] Processing: {title[:50]}...")
print(f" ID: {content_id}")
try:
# Use the pipeline to process the content
# The pipeline expects content_text - we'll use transcript or summary
content_text = item.transcript or item.summary or ""
result = pipeline.process_full(item, content_text)
# Save the updated content
storage.save_content(result)
print(f" ✅ Success")
print(f" 📄 Note: {result.output_path or 'N/A'}")
print(f" 🏷️ Tags: {', '.join(result.tags)}")
success_count += 1
except Exception as e:
print(f" ❌ Error: {e}")
import traceback
traceback.print_exc()
fail_count += 1
print()
# Summary
print(f"\n📊 Processing Complete")
print(f" Successful: {success_count}")
print(f" Failed: {fail_count}")
print(f" Total: {len(items)}")
return 0 if fail_count == 0 else 1
def cmd_skip(args: argparse.Namespace):
"""Handle skip command - skip/remove an item from inbox."""
print_header("Skip Content")
if not CONFIG_AVAILABLE:
print("Error: Storage module not available.")
return 1
storage = Storage()
# Find the item
content = storage.load_content(args.content_id)
if not content:
print(f"❌ Content not found: {args.content_id}")
return 1
print(f"Skipping: {content.title or 'Untitled'}")
print(f"ID: {args.content_id}")
if args.reason:
print(f"Reason: {args.reason}")
# Mark as skipped
content.status = ContentStatus.SKIPPED
if args.reason:
content.summary = f"[Skipped: {args.reason}] {content.summary or ''}"
storage.save_content(content)
print("✅ Item skipped and marked as skipped.")
if args.reason:
print(f" Reason recorded: {args.reason}")
return 0
def cmd_status(args: argparse.Namespace):
"""Handle status command - show system status."""
print_header("System Status")
# Git status
print("📁 Git Repository Status")
print("-" * 30)
import subprocess
try:
# Check if we're in a git repo
result = subprocess.run(
['git', 'status', '--short'],
capture_output=True,
text=True,
cwd=os.getcwd()
)
if result.returncode == 0:
if result.stdout.strip():
print("Uncommitted changes:")
print(result.stdout)
else:
print("✅ Working directory clean")
# Get branch info
branch_result = subprocess.run(
['git', 'branch', '--show-current'],
capture_output=True,
text=True
)
if branch_result.returncode == 0:
print(f"Branch: {branch_result.stdout.strip()}")
else:
print("⚠️ Not a git repository or git not available")
except FileNotFoundError:
print("⚠️ Git not available")
except Exception as e:
print(f"⚠️ Git error: {e}")
print()
# Inbox statistics
print("📥 Inbox Statistics")
print("-" * 30)
if CONFIG_AVAILABLE:
storage = Storage()
stats = storage.get_stats()
print(f"Pending (confirmed): {stats.get(ContentStatus.CONFIRMED.value, 0)}")
print(f"New: {stats.get(ContentStatus.NEW.value, 0)}")
print(f"Scanned: {stats.get(ContentStatus.SCANNED.value, 0)}")
print(f"Analyzed: {stats.get(ContentStatus.ANALYZED.value, 0)}")
print(f"Generated: {stats.get(ContentStatus.GENERATED.value, 0)}")
print(f"Skipped: {stats.get(ContentStatus.SKIPPED.value, 0)}")
else:
print("⚠️ Storage module not available")
print()
# Configuration summary
print("⚙️ Configuration")
print("-" * 30)
if CONFIG_AVAILABLE:
config = Config()
subscriptions = config.get('subscriptions', [])
print(f"Active subscriptions: {len(subscriptions)}")
if subscriptions:
for sub in subscriptions:
print(f" • {sub.get('name', 'Unnamed')} ({sub.get('platform')})")
else:
print("⚠️ Config module not available")
return 0
def cmd_search(args: argparse.Namespace):
"""Handle search command - search processed notes."""
print_header(f"Search: '{args.query}'")
if not CONFIG_AVAILABLE:
print("Error: Storage module not available.")
return 1
storage = Storage()
# Perform search across all content
results = storage.search(args.query)
if not results:
print("🔍 No results found.")
return 0
# Filter by tags if requested
if args.in_tags:
query_lower = args.query.lower()
results = [r for r in results if any(query_lower in tag.lower() for tag in r.tags)]
if not results:
print("🔍 No results found in tags.")
return 0
print(f"Found {len(results)} result(s):\n")
# Format results
for i, content in enumerate(results, 1):
title = content.title or 'Untitled'
path = content.output_path or f"inbox/{content.id}.json"
tags = content.tags
print(f"{i}. {title}")
print(f" ID: {content.id}")
print(f" Status: {content.status.value}")
print(f" Path: {path}")
if tags:
print(f" Tags: {', '.join(tags)}")
# Show matching content preview
if not args.in_tags and content.summary:
content_text = content.summary
query_lower = args.query.lower()
idx = content_text.lower().find(query_lower)
if idx >= 0:
start = max(0, idx - 30)
end = min(len(content_text), idx + len(args.query) + 30)
preview = content_text[start:end]
if start > 0:
preview = "..." + preview
if end < len(content_text):
preview = preview + "..."
print(f" Match: {preview}")
print()
return 0
def main():
"""Main entry point for CLI."""
parser = argparse.ArgumentParser(
description='Obsidian KB 知识库维护工具',
prog='obsidian-kb',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s subscribe bilibili fav 12345 --name="Tech Favorites"
%(prog)s inbox --limit 10 --sort date
%(prog)s process all --yes
%(prog)s process abc123
%(prog)s skip abc123 --reason="Low quality"
%(prog)s status
%(prog)s search "python tutorial" --in-tags
"""
)
subparsers = parser.add_subparsers(dest='command', help='Available commands')
# subscribe 子命令
subscribe_parser = subparsers.add_parser(
'subscribe',
aliases=['sub'],
help='Subscribe to a content source'
)
subscribe_parser.add_argument(
'platform',
choices=['bilibili', 'youtube', 'zhihu'],
help='Platform to subscribe to'
)
subscribe_parser.add_argument(
'type',
choices=['fav', 'up', 'channel'],
help='Subscription type'
)
subscribe_parser.add_argument(
'id',
help='Platform-specific ID (user ID, channel ID, etc.)'
)
subscribe_parser.add_argument(
'--name',
help='Friendly name for this subscription'
)
subscribe_parser.set_defaults(func=cmd_subscribe)
# inbox 子命令
inbox_parser = subparsers.add_parser(
'inbox',
aliases=['i'],
help='Show inbox contents'
)
inbox_parser.add_argument(
'--limit',
type=int,
default=20,
help='Maximum number of items to show (default: 20)'
)
inbox_parser.add_argument(
'--sort',
choices=['score', 'date'],
default='score',
help='Sort order (default: score)'
)
inbox_parser.set_defaults(func=cmd_inbox)
# process 子命令
process_parser = subparsers.add_parser(
'process',
aliases=['p'],
help='Process content items'
)
process_parser.add_argument(
'content_id',
nargs='?',
default='all',
help='Content ID to process, or "all" for all items (default: all)'
)
process_parser.add_argument(
'--yes',
action='store_true',
help='Auto-confirm without prompting'
)
process_parser.set_defaults(func=cmd_process)
# skip 子命令
skip_parser = subparsers.add_parser(
'skip',
aliases=['s'],
help='Skip an item (remove from inbox)'
)
skip_parser.add_argument(
'content_id',
help='Content ID to skip'
)
skip_parser.add_argument(
'--reason',
help='Reason for skipping'
)
skip_parser.set_defaults(func=cmd_skip)
# status 子命令
status_parser = subparsers.add_parser(
'status',
aliases=['st'],
help='Show system status'
)
status_parser.set_defaults(func=cmd_status)
# search 子命令
search_parser = subparsers.add_parser(
'search',
aliases=['q'],
help='Search processed notes'
)
search_parser.add_argument(
'query',
help='Search query'
)
search_parser.add_argument(
'--in-tags',
action='store_true',
help='Search only in tags'
)
search_parser.set_defaults(func=cmd_search)
# Parse arguments
args = parser.parse_args()
# Execute command
if args.command is None:
parser.print_help()
return 0
try:
return args.func(args) or 0
except KeyboardInterrupt:
print("\n\n⚠️ Interrupted by user")
return 130
except Exception as e:
print(f"\n❌ Error: {e}")
import traceback
traceback.print_exc()
return 1
if __name__ == '__main__':
sys.exit(main())
-353
View File
@@ -1,353 +0,0 @@
"""
Configuration management for Obsidian KB Skill.
"""
import os
import yaml
from pathlib import Path
from typing import Optional, Dict, Any
# Global config instance
_config_instance: Optional['Config'] = None
def get_config(config_path: Optional[str] = None) -> 'Config':
"""Get global config instance."""
global _config_instance
if _config_instance is None or config_path:
_config_instance = Config(config_path)
return _config_instance
class Config:
"""Configuration manager."""
DEFAULT_CONFIG = {
'ai': {
'provider': 'openai',
'model': 'gpt-4',
'api_key': None,
},
'storage': {
'inbox_dir': 'inbox',
'topics_dir': 'topics',
'data_dir': 'data',
},
'git': {
'enabled': False,
'vault_path': '~/Documents/Obsidian/KB',
'remote': 'origin',
'branch': 'main',
'auto_push': False,
'commit_message_template': 'Add note: {title}',
},
'obsidian': {
'vault_path': '~/Documents/Obsidian/KB',
'notes_folder': 'Notes',
},
'chunking': {
'enabled': True,
'chunk_duration_seconds': 900, # 15 minutes
'max_chunk_duration_seconds': 1800, # 30 minutes
'long_video_threshold': 1800, # 30 minutes
},
'output': {
'organize_by': 'author', # author | date | topic
'filename_format': '{date}_{title}.md',
'auto_tags': True,
'default_tags': ['video', 'bilibili'],
},
'sources': {
'bilibili': {
'enabled': True,
'cookie': None,
'extraction': {
'headless': True,
'timeout': 30,
},
},
'youtube': {
'enabled': False,
},
'zhihu': {
'enabled': False,
},
},
}
def __init__(self, config_path: Optional[str] = None, load_env: bool = True):
"""
Initialize configuration.
Args:
config_path: Path to config file. If None, uses default locations.
load_env: Whether to load environment variable overrides.
"""
self.config_path = config_path or self._find_config_file()
self._config = self._load_config(load_env)
def _find_config_file(self) -> str:
"""Find configuration file in standard locations."""
search_paths = [
Path.cwd() / 'config.yaml',
Path.cwd() / 'config.yml',
Path.home() / '.obsidian-kb' / 'config.yaml',
Path.home() / '.config' / 'obsidian-kb' / 'config.yaml',
]
for path in search_paths:
if path.exists():
return str(path)
# Return default path
return str(Path.cwd() / 'config.yaml')
def _load_config(self, load_env: bool = True) -> Dict[str, Any]:
"""Load configuration from file or use defaults."""
config = self.DEFAULT_CONFIG.copy()
if Path(self.config_path).exists():
with open(self.config_path, 'r', encoding='utf-8') as f:
user_config = yaml.safe_load(f)
if user_config:
self._deep_update(config, user_config)
# Override with environment variables
if load_env:
self._apply_env_overrides(config)
return config
def _deep_update(self, base: Dict, update: Dict) -> None:
"""Deep update dictionary."""
for key, value in update.items():
if key in base and isinstance(base[key], dict) and isinstance(value, dict):
self._deep_update(base[key], value)
else:
base[key] = value
def _apply_env_overrides(self, config: Dict[str, Any]) -> None:
"""Apply environment variable overrides."""
# AI settings
if os.getenv('OPENAI_API_KEY'):
config['ai']['api_key'] = os.getenv('OPENAI_API_KEY')
if os.getenv('AI_MODEL'):
config['ai']['model'] = os.getenv('AI_MODEL')
# Git settings
if os.getenv('OBSIDIAN_VAULT_PATH'):
config['git']['vault_path'] = os.getenv('OBSIDIAN_VAULT_PATH')
# Bilibili cookie
if os.getenv('BILIBILI_COOKIE'):
config['sources']['bilibili']['cookie'] = os.getenv('BILIBILI_COOKIE')
def get(self, key: str, default: Any = None) -> Any:
"""
Get configuration value using dot notation.
Args:
key: Key in dot notation (e.g., 'ai.model')
default: Default value if key not found
Returns:
Configuration value
"""
keys = key.split('.')
value = self._config
for k in keys:
if isinstance(value, dict) and k in value:
value = value[k]
else:
return default
return value
def set(self, key: str, value: Any) -> None:
"""
Set configuration value using dot notation.
Args:
key: Key in dot notation
value: Value to set
"""
keys = key.split('.')
config = self._config
for k in keys[:-1]:
if k not in config:
config[k] = {}
config = config[k]
config[keys[-1]] = value
def save(self) -> None:
"""Save configuration to file."""
Path(self.config_path).parent.mkdir(parents=True, exist_ok=True)
with open(self.config_path, 'w', encoding='utf-8') as f:
yaml.dump(self._config, f, default_flow_style=False, allow_unicode=True)
@property
def obsidian_path(self) -> str:
"""Get obsidian path (alias for vault_path)."""
return str(self.vault_path)
@property
def vault_path(self) -> Path:
"""Get Obsidian vault path."""
path = self.get('obsidian.vault_path') or self.get('git.vault_path', '~/Documents/Obsidian/KB')
return Path(path).expanduser()
@property
def notes_folder(self) -> str:
"""Get notes folder."""
return self.get('obsidian.notes_folder', 'Notes')
@property
def ai_api_key(self) -> Optional[str]:
"""Get AI API key."""
return self.get('ai.api_key')
@property
def ai_model(self) -> str:
"""Get AI model."""
return self.get('ai.model', 'gpt-4')
@property
def chunking_enabled(self) -> bool:
"""Check if chunking is enabled."""
return self.get('chunking.enabled', True)
@property
def chunk_duration(self) -> int:
"""Get chunk duration in seconds."""
return self.get('chunking.chunk_duration_seconds', 900)
@property
def long_video_threshold(self) -> int:
"""Get long video threshold in seconds."""
return self.get('chunking.long_video_threshold', 1800)
@property
def quality_threshold(self) -> float:
"""Get quality threshold."""
return self.get('quality_threshold', 0.7)
@property
def obsidian(self) -> 'Config':
"""Get obsidian config section (returns self for compatibility)."""
return self
@property
def git(self) -> 'Config':
"""Get git config section (returns self for compatibility)."""
return self
@property
def enabled(self) -> bool:
"""Check if git is enabled."""
return self.get('git.enabled', False)
@property
def auto_push(self) -> bool:
"""Check if auto push is enabled."""
return self.get('git.auto_push', False)
@property
def commit_message_template(self) -> str:
"""Get commit message template."""
return self.get('git.commit_message_template', 'Add note: {title}')
@property
def kimi_api_key(self) -> str:
"""Get Kimi API key (alias for ai.api_key)."""
return self.get('ai.api_key') or ''
# Output configuration
@property
def organize_by(self) -> str:
"""Get output organize_by strategy."""
return self.get('output.organize_by', 'author')
@property
def filename_format(self) -> str:
"""Get output filename format."""
return self.get('output.filename_format', '{date}_{title}.md')
@property
def auto_tags(self) -> bool:
"""Get auto_tags setting."""
return self.get('output.auto_tags', True)
@property
def default_tags(self) -> list:
"""Get default tags list."""
return self.get('output.default_tags', ['video', 'bilibili'])
# Bilibili extraction configuration
@property
def bilibili_headless(self) -> bool:
"""Get bilibili extraction headless mode."""
return self.get('sources.bilibili.extraction.headless', True)
@property
def bilibili_timeout(self) -> int:
"""Get bilibili extraction timeout in seconds."""
return self.get('sources.bilibili.extraction.timeout', 30)
# Zhihu DrissionPage configuration
@property
def zhihu_enabled(self) -> bool:
"""知乎是否启用"""
return self.get('sources.zhihu.enabled', False)
@property
def zhihu_use_drissionpage(self) -> bool:
"""知乎是否使用 DrissionPage"""
return self.get('sources.zhihu.use_drissionpage', True)
@property
def zhihu_cookie_file(self) -> Optional[str]:
"""知乎 Cookie 文件路径"""
return self.get('sources.zhihu.cookie_file')
@property
def zhihu_cookie_str(self) -> Optional[str]:
"""知乎 Cookie 字符串"""
return self.get('sources.zhihu.cookie_str')
@property
def zhihu_timeout(self) -> int:
"""知乎提取超时时间(秒)"""
return self.get('sources.zhihu.timeout', 30)
@property
def zhihu_headless(self) -> bool:
"""知乎是否使用无头浏览器"""
return self.get('sources.zhihu.headless', True)
def ensure_dirs(self) -> None:
"""Ensure required directories exist."""
self.vault_path.mkdir(parents=True, exist_ok=True)
(self.vault_path / self.get('storage.inbox_dir', 'Inbox')).mkdir(exist_ok=True)
(self.vault_path / self.get('storage.topics_dir', 'Topics')).mkdir(exist_ok=True)
def validate(self) -> tuple[bool, list[str]]:
"""Validate configuration."""
errors = []
if not self.ai_api_key:
errors.append("AI API key not configured")
return len(errors) == 0, errors
# Subscriptions support
def get_subscriptions(self) -> list:
"""Get subscriptions list."""
return self.get('subscriptions', [])
def add_subscription(self, subscription: Dict[str, Any]) -> None:
"""Add a new subscription."""
subs = self.get('subscriptions', [])
subs.append(subscription)
self.set('subscriptions', subs)
self.save()
-486
View File
@@ -1,486 +0,0 @@
"""Obsidian KB - 内容分片器
C5.1-C5.2: 实现长视频分段和分段策略
"""
import re
from dataclasses import dataclass
from typing import List, Dict, Tuple, Optional, Any
@dataclass
class Chunk:
"""内容分片数据模型"""
index: int
start_time: float # seconds
end_time: float # seconds
text: str
@property
def duration(self) -> float:
"""Get chunk duration in seconds."""
return self.end_time - self.start_time
@property
def time_range(self) -> str:
"""Get formatted time range string."""
return f"{self._format_time(self.start_time)}-{self._format_time(self.end_time)}"
def _format_time(self, seconds: float) -> str:
"""Format seconds to MM:SS or HH:MM:SS."""
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 to_dict(self) -> Dict:
"""转换为字典"""
return {
'index': self.index,
'start_time': self.start_time,
'end_time': self.end_time,
'text_length': len(self.text),
'text_preview': self.text[:100] + '...' if len(self.text) > 100 else self.text
}
def __repr__(self) -> str:
return f"Chunk({self.index}, {self.start_time}-{self.end_time}, {len(self.text)}chars)"
class ContentChunker:
"""内容分片器 - 支持SRT字幕分片"""
def __init__(self, chunk_duration_seconds: int = 900, max_chunk_duration: int = 1800):
"""
Initialize chunker.
Args:
chunk_duration_seconds: Target duration for each chunk (default 15 min)
max_chunk_duration: Maximum duration for a chunk (default 30 min)
"""
self.chunk_duration = chunk_duration_seconds
self.max_chunk_duration = max_chunk_duration
# SRT时间戳正则
self.srt_time_pattern = re.compile(
r'(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})'
)
def chunk_from_extractor(self, extractor_data: Dict[str, Any]) -> List[Chunk]:
"""
从提取器数据结构接收输入并进行分片
Args:
extractor_data: 提取器返回的数据结构,包含:
- metadata: Dict[str, Any] - 元数据(包含时长等)
- stats: Dict[str, Any] - 统计信息
- subtitle: str - 字幕/文本内容
- description: str - 内容描述
Returns:
Chunk对象列表
"""
metadata = extractor_data.get('metadata', {})
subtitle = extractor_data.get('subtitle', '')
description = extractor_data.get('description', '')
# 检测是否有字幕
has_subtitle = bool(subtitle and subtitle.strip())
if not has_subtitle:
# 无字幕:返回空列表或单块(description)
print("[ContentChunker] 无字幕,返回空分片列表")
return []
# 获取时长
duration = metadata.get('duration', 0)
print(f"[ContentChunker] 有字幕,时长={duration}秒")
# 使用原有分段逻辑
return self._auto_chunk(subtitle, duration)
def _auto_chunk(self, subtitle_text: str, duration: int) -> List[Chunk]:
"""
自动分片(内部方法)
Args:
subtitle_text: 字幕文本
duration: 视频时长(秒)
Returns:
Chunk对象列表
"""
# 使用原有的 chunk_content 逻辑
return chunk_content(subtitle_text, duration)
def chunk_by_duration(self, srt_content: str, target_duration: Optional[int] = None) -> List[Chunk]:
"""
Split SRT content into chunks based on duration.
Args:
srt_content: SRT formatted subtitle text
target_duration: Target chunk duration in seconds (overrides default)
Returns:
List of Chunk objects
"""
target = target_duration or self.chunk_duration
subtitles = self._parse_srt(srt_content)
if not subtitles:
return []
chunks = []
current_chunk_texts = []
current_start = subtitles[0][0]
current_end = current_start
chunk_index = 0
for start, end, text in subtitles:
# Check if adding this subtitle would exceed target duration
if current_chunk_texts and (end - current_start) > target:
# Save current chunk
chunks.append(Chunk(
index=chunk_index,
start_time=current_start,
end_time=current_end,
text=' '.join(current_chunk_texts)
))
chunk_index += 1
# Start new chunk
current_chunk_texts = [text]
current_start = start
current_end = end
else:
current_chunk_texts.append(text)
current_end = end
# Don't forget the last chunk
if current_chunk_texts:
chunks.append(Chunk(
index=chunk_index,
start_time=current_start,
end_time=current_end,
text=' '.join(current_chunk_texts)
))
return chunks
def _parse_srt(self, srt_content: str) -> List[Tuple[float, float, str]]:
"""
Parse SRT content into list of (start_time, end_time, text) tuples.
Args:
srt_content: SRT formatted text
Returns:
List of tuples (start_seconds, end_seconds, text)
"""
import re
subtitles = []
# Split by double newlines to get individual subtitle entries
entries = re.split(r'\n\s*\n', srt_content.strip())
for entry in entries:
lines = entry.strip().split('\n')
if len(lines) < 2:
continue
# Find the timing line (contains -->)
timing_line = None
for line in lines:
if '-->' in line:
timing_line = line
break
if not timing_line:
continue
# Parse timing
match = re.match(
r'\d*\s*(\d{2}:\d{2}:\d{2},\d{3})\s*--\u003e\s*(\d{2}:\d{2}:\d{2},\d{3})',
timing_line.strip()
)
if not match:
continue
start_time = self._time_to_seconds(match.group(1))
end_time = self._time_to_seconds(match.group(2))
# Get text (lines after timing line)
text_lines = []
found_timing = False
for line in lines:
if found_timing:
text_lines.append(line.strip())
elif '-->' in line:
found_timing = True
text = ' '.join(text_lines).strip()
if text:
subtitles.append((start_time, end_time, text))
return subtitles
def chunk_by_count(self, srt_text: str, num_chunks: int) -> List[Chunk]:
"""
按指定段数分割SRT字幕
Args:
srt_text: SRT格式字幕文本
num_chunks: 目标段数
Returns:
Chunk对象列表
"""
print(f"[ContentChunker] 按段数分片: 目标段数={num_chunks}")
# 解析SRT条目
entries = self._parse_srt_dict(srt_text)
if not entries:
return []
if num_chunks <= 0:
num_chunks = 1
# 计算每段应包含的条目数
total_entries = len(entries)
entries_per_chunk = max(1, total_entries // num_chunks)
chunks = []
current_chunk_texts = []
current_start_time = None
chunk_index = 0
entry_count = 0
for i, entry in enumerate(entries):
if current_start_time is None:
current_start_time = entry['start']
current_chunk_texts.append(entry['text'])
entry_count += 1
# 检查是否达到每段条目数,或是最后一个条目
is_last_entry = (i == total_entries - 1)
should_split = (entry_count >= entries_per_chunk and chunk_index < num_chunks - 1)
if should_split or is_last_entry:
chunk = Chunk(
index=chunk_index,
start_time=self._time_to_seconds(current_start_time),
end_time=self._time_to_seconds(entry['end']),
text='\n'.join(current_chunk_texts)
)
chunks.append(chunk)
current_chunk_texts = []
current_start_time = None
chunk_index += 1
entry_count = 0
print(f"[ContentChunker] 分片完成: 共{len(chunks)}段")
return chunks
def _parse_srt_dict(self, srt_text: str) -> List[Dict]:
"""
解析SRT字幕文本,返回字典格式(用于内部使用)
Returns:
条目列表,每个条目包含start, end, text
"""
entries = []
# 首先检查是否包含SRT时间戳格式
if '-->' not in srt_text:
# 非SRT格式,按行数分割模拟
lines = srt_text.strip().split('\n')
# 模拟每10行一个条目,每个条目30秒
for i in range(0, len(lines), 10):
chunk_lines = lines[i:i+10]
start_sec = i * 3 # 每行约3秒
end_sec = (i + 10) * 3
entries.append({
'start': self._seconds_to_time(start_sec),
'end': self._seconds_to_time(end_sec),
'text': '\n'.join(chunk_lines)
})
return entries
# 按空行分割条目
blocks = re.split(r'\n\s*\n', srt_text.strip())
for block in blocks:
lines = block.strip().split('\n')
if len(lines) < 2:
continue
# 查找时间戳行(通常是第二行,但可能有变体)
time_line = None
for line in lines:
if '-->' in line:
time_line = line
break
if not time_line:
continue
# 解析时间戳
match = self.srt_time_pattern.search(time_line)
if not match:
continue
start_time = match.group(1)
end_time = match.group(2)
# 提取文本(时间戳行之后的所有行)
time_line_idx = lines.index(time_line) if time_line in lines else 1
text_lines = lines[time_line_idx + 1:]
text = '\n'.join(text_lines).strip()
if text:
entries.append({
'start': start_time,
'end': end_time,
'text': text
})
return entries
def _time_to_seconds(self, time_str: str) -> float:
"""将SRT时间格式转换为秒数"""
# 处理 "00:05:23,450" 格式
time_str = time_str.replace(',', '.')
parts = time_str.split(':')
if len(parts) == 3:
hours = int(parts[0])
minutes = int(parts[1])
seconds = float(parts[2])
return hours * 3600 + minutes * 60 + seconds
return 0.0
def _seconds_to_time(self, seconds: int) -> str:
"""将秒数转换为SRT时间格式"""
hours = seconds // 3600
minutes = (seconds % 3600) // 60
secs = seconds % 60
return f"{hours:02d}:{minutes:02d}:{secs:02d},000"
def get_chunking_strategy(self, total_duration: float) -> dict:
"""
Determine chunking strategy based on total content duration.
Args:
total_duration: Total duration in seconds
Returns:
Dictionary with chunking strategy
"""
if total_duration < 600: # Less than 10 minutes
return {
'strategy': 'direct',
'chunk_count': 1,
'description': 'Short content, no chunking needed'
}
elif total_duration < 1800: # 10-30 minutes
return {
'strategy': 'truncate',
'chunk_count': 1,
'description': 'Medium content, single chunk with possible truncation'
}
elif total_duration < 3600: # 30-60 minutes
return {
'strategy': 'two_part',
'chunk_count': 2,
'description': 'Long content, split into 2 chunks'
}
else: # 60+ minutes
chunk_count = int(total_duration / self.chunk_duration) + 1
return {
'strategy': 'multi_part',
'chunk_count': chunk_count,
'description': f'Very long content, split into {chunk_count} chunks'
}
def get_chunking_strategy(duration_seconds: int) -> Dict:
"""
C5.2: 根据视频时长获取分段策略
Args:
duration_seconds: 视频时长(秒)
Returns:
分段策略字典
"""
if duration_seconds < 600:
# 少于10分钟:直接处理,无需分片
return {
'use_chunking': False,
'strategy': 'direct',
'description': '短视频,直接处理'
}
elif duration_seconds < 1800:
# 10-30分钟:截断处理
return {
'use_chunking': False,
'strategy': 'truncate',
'max_chars': 8000,
'description': '中等视频,截断至8000字符处理'
}
elif duration_seconds < 3600:
# 30-60分钟:分为2段
return {
'use_chunking': True,
'num_chunks': 2,
'strategy': 'chunk_2',
'chunk_duration': duration_seconds // 2,
'description': '长视频,分为2段处理'
}
else:
# 60分钟以上:每15分钟一段
num_chunks = max(4, duration_seconds // 900)
return {
'use_chunking': True,
'chunk_duration': 900,
'num_chunks': num_chunks,
'strategy': 'chunk_n',
'description': f'超长视频,分为{num_chunks}段,每段约15分钟'
}
# 便捷函数
def chunk_content(srt_text: str, duration_seconds: int) -> List[Chunk]:
"""
根据视频时长自动选择最佳分片策略
Args:
srt_text: SRT字幕文本
duration_seconds: 视频时长(秒)
Returns:
Chunk列表
"""
strategy = get_chunking_strategy(duration_seconds)
chunker = ContentChunker()
if not strategy['use_chunking']:
# 不需要分片,返回单一大段
return [Chunk(
text=srt_text[:strategy.get('max_chars', len(srt_text))],
start_time="00:00:00,000",
end_time=chunker._seconds_to_time(duration_seconds),
index=0
)]
if 'num_chunks' in strategy and strategy['strategy'] == 'chunk_2':
return chunker.chunk_by_count(srt_text, strategy['num_chunks'])
else:
return chunker.chunk_by_duration(srt_text, strategy.get('chunk_duration', 900))
-438
View File
@@ -1,438 +0,0 @@
"""Obsidian KB - 深度分析器
C4.2: 实现深度分析,根据时长选择不同分析策略
"""
from typing import Dict, Any, List, Optional
from src.models import Content, AnalysisResult, ContentStatus, TimelineEntry
from src.config import get_config
class DeepAnalyzer:
"""深度内容分析器 - 根据内容时长选择不同策略"""
# 时长阈值(秒)
SHORT_VIDEO_THRESHOLD = 600 # 10分钟
LONG_VIDEO_THRESHOLD = 1800 # 30分钟
def __init__(self, ai_client=None):
self.config = get_config()
self.ai_client = ai_client
def analyze(self, extractor_data: Dict[str, Any]) -> AnalysisResult:
"""
根据内容时长选择分析策略(从提取器数据接收输入)
Args:
extractor_data: 提取器返回的数据结构,包含:
- metadata: Dict[str, Any] - 元数据(标题、作者、时长等)
- stats: Dict[str, Any] - 统计信息
- subtitle: str - 字幕/文本内容
- description: str - 内容描述
Returns:
AnalysisResult分析结果
"""
metadata = extractor_data.get('metadata', {})
subtitle = extractor_data.get('subtitle', '')
description = extractor_data.get('description', '')
# 从metadata中提取信息
title = metadata.get('title', '未知')
duration = metadata.get('duration', 0)
content_type = metadata.get('content_type', 'video')
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:
# 有字幕:使用 AI 深度分析
print(f"[DeepAnalyzer] 使用AI深度分析(有字幕)")
# 根据时长选择策略
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(self, content: Content, content_text: str) -> AnalysisResult:
"""短视频/短内容轻量分析(旧版接口,保留兼容)"""
result = AnalysisResult()
# 截断文本
text_sample = self._get_text_sample(content_text, max_chars=5000)
# 生成简洁分析
if self.ai_client:
analysis = self._analyze_with_ai_short(text_sample, content)
result.main_topics = analysis.get('topics', [])
result.key_insights = analysis.get('insights', [])
result.action_items = analysis.get('actions', [])
result.summary = analysis.get('summary', '')
else:
# 无AI时的简单分析
result.main_topics = ["主题1", "主题2", "主题3"]
result.key_insights = ["关键见解1", "关键见解2"]
result.action_items = ["建议行动1"]
result.summary = content.summary or "无摘要"
# 短视频的时间轴较简单
result.timeline = self._generate_simple_timeline(content)
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)
# 生成简洁分析
if self.ai_client:
analysis = self._analyze_with_ai_short_text(text_sample, title, duration)
result.main_topics = analysis.get('topics', [])
result.key_insights = analysis.get('insights', [])
result.action_items = analysis.get('actions', [])
result.summary = analysis.get('summary', '')
else:
# 无AI时的简单分析
result.main_topics = ["主题1", "主题2", "主题3"]
result.key_insights = ["关键见解1", "关键见解2"]
result.action_items = ["建议行动1"]
result.summary = title or "无摘要"
# 短视频的时间轴较简单
result.timeline = self._generate_simple_timeline_from_duration(title, duration)
return result
def _analyze_long(self, content: Content, content_text: str) -> AnalysisResult:
"""长视频详细时间轴分析(旧版接口,保留兼容)"""
result = AnalysisResult()
# 如果有AI客户端且支持分段分析
if self.ai_client and hasattr(self.ai_client, '_deep_analyze'):
# 使用AI客户端的分段分析能力
analysis = self.ai_client._deep_analyze(
content_text=content_text,
content_type=content.content_type.value,
duration=content.duration
)
result.main_topics = analysis.get('topics', [])
result.key_insights = analysis.get('insights', [])
result.action_items = analysis.get('actions', [])
result.summary = analysis.get('summary', '')
result.full_analysis = analysis.get('full_analysis', '')
result.timeline = analysis.get('timeline', [])
else:
# 基础分段分析
result = self._analyze_long_basic(content, content_text)
return result
def _analyze_long_with_subtitle(self, title: str, subtitle: str, duration: int, content_type: str) -> AnalysisResult:
"""有字幕的长视频详细时间轴分析(新接口)"""
result = AnalysisResult()
# 如果有AI客户端且支持分段分析
if self.ai_client and hasattr(self.ai_client, '_deep_analyze'):
# 使用AI客户端的分段分析能力
analysis = self.ai_client._deep_analyze(
content_text=subtitle,
content_type=content_type,
duration=duration
)
result.main_topics = analysis.get('topics', [])
result.key_insights = analysis.get('insights', [])
result.action_items = analysis.get('actions', [])
result.summary = analysis.get('summary', '')
result.full_analysis = analysis.get('full_analysis', '')
result.timeline = analysis.get('timeline', [])
else:
# 基础分段分析
result = self._analyze_long_basic_new(title, subtitle, duration)
return result
def _analyze_fallback(self, title: str, description: str, metadata: Dict[str, Any]) -> AnalysisResult:
"""
无字幕降级处理:使用 description 或默认提示,只返回 default_tags
Args:
title: 内容标题
description: 内容描述
metadata: 元数据
Returns:
AnalysisResult 分析结果(默认标签)
"""
result = AnalysisResult()
# 默认标签
default_tags = ["未分类", "待处理"]
# 尝试从metadata和description提取信息
author = metadata.get('author', '')
source = metadata.get('source', '')
# 构建摘要
if description and description.strip():
# 使用 description 的前200字作为摘要
summary = description[:200] + "..." if len(description) > 200 else description
print(f"[DeepAnalyzer] 降级处理: 使用 description 作为摘要")
else:
summary = f"无字幕内容,标题:{title}"
print(f"[DeepAnalyzer] 降级处理: 使用标题作为摘要")
# 尝试从标题和作者提取标签
tags = default_tags.copy()
if author:
tags.append(f"作者:{author}")
if source:
tags.append(f"来源:{source}")
# 如果有AI客户端,尝试从description中提取标签
if self.ai_client and description:
try:
prompt = f"""请从以下内容描述中提取3-5个标签:
标题:{title}
描述:{description[:1000]}
请返回JSON格式:
{{
"tags": ["标签1", "标签2", "标签3"]
}}"""
response = self.ai_client.generate_json(prompt)
extracted_tags = response.get('tags', [])
if extracted_tags:
tags.extend(extracted_tags)
print(f"[DeepAnalyzer] 降级处理: AI 提取了 {len(extracted_tags)} 个标签")
except Exception as e:
print(f"[DeepAnalyzer] 降级处理: AI 标签提取失败: {e}")
# 设置结果
result.summary = summary
result.main_topics = tags[:3] # 前3个作为主题
result.recommended_tags = tags
result.key_insights = ["无字幕内容,仅能从描述获取基本信息"]
result.action_items = ["建议补充字幕以便深度分析"]
result.timeline = [] # 无字幕无法生成时间轴
print(f"[DeepAnalyzer] 降级处理完成: 主题数={len(result.main_topics)}, 标签数={len(result.recommended_tags)}")
return result
def _analyze_long_basic(self, content: Content, content_text: str) -> AnalysisResult:
"""基础长视频分析(无AI时,旧版接口)"""
result = AnalysisResult()
# 简单的文本分割分析
duration = content.duration or 3600
# 根据时长决定分割段数
if duration < 3600: # 30-60分钟
num_segments = 3
elif duration < 7200: # 60-120分钟
num_segments = 4
else: # >120分钟
num_segments = 6
# 生成时间轴
segment_duration = duration // num_segments
timeline = []
for i in range(num_segments):
start_sec = i * segment_duration
end_sec = min((i + 1) * segment_duration, duration)
timeline.append({
'index': i + 1,
'start_time': self._format_time(start_sec),
'end_time': self._format_time(end_sec),
'title': f"第{i+1}部分",
'summary': f"时间段 {self._format_time(start_sec)} - {self._format_time(end_sec)} 的内容"
})
result.timeline = timeline
result.main_topics = ["主题A", "主题B", "主题C", "主题D"]
result.key_insights = ["深入见解1", "深入见解2", "深入见解3"]
result.action_items = ["建议行动1", "建议行动2"]
result.summary = f"这是一个{self._format_time(duration)}的长视频,分为{num_segments}个主要部分进行分析。"
return result
def _analyze_long_basic_new(self, title: str, subtitle: str, duration: int) -> AnalysisResult:
"""基础长视频分析(无AI时,新版接口)"""
result = AnalysisResult()
# 根据时长决定分割段数
if duration < 3600: # 30-60分钟
num_segments = 3
elif duration < 7200: # 60-120分钟
num_segments = 4
else: # >120分钟
num_segments = 6
# 生成时间轴
segment_duration = duration // num_segments
timeline = []
for i in range(num_segments):
start_sec = i * segment_duration
end_sec = min((i + 1) * segment_duration, duration)
timeline.append({
'index': i + 1,
'start_time': self._format_time(start_sec),
'end_time': self._format_time(end_sec),
'title': f"第{i+1}部分",
'summary': f"时间段 {self._format_time(start_sec)} - {self._format_time(end_sec)} 的内容"
})
result.timeline = timeline
result.main_topics = ["主题A", "主题B", "主题C", "主题D"]
result.key_insights = ["深入见解1", "深入见解2", "深入见解3"]
result.action_items = ["建议行动1", "建议行动2"]
result.summary = f"标题:{title},时长:{self._format_time(duration)},分为{num_segments}个主要部分进行分析。"
return result
def _analyze_with_ai_short(self, text: str, content: Content) -> Dict[str, Any]:
"""使用AI进行短视频分析(旧版接口)"""
prompt = f"""请分析以下内容,以JSON格式返回:
{text[:3000]}
请返回以下格式的JSON:
{{
"summary": "整体摘要(100字以内)",
"topics": ["主题1", "主题2", "主题3"],
"insights": ["关键见解1", "关键见解2"],
"actions": ["建议行动1"]
}}"""
try:
response = self.ai_client.generate_json(prompt)
return response
except Exception as e:
print(f"[DeepAnalyzer] AI分析失败: {e}")
return {
"summary": content.summary or "分析失败",
"topics": ["主题1", "主题2", "主题3"],
"insights": ["无法获取深入见解"],
"actions": []
}
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:
response = self.ai_client.generate_json(prompt)
return response
except Exception as e:
print(f"[DeepAnalyzer] AI分析失败: {e}")
return {
"summary": title or "分析失败",
"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[len(text)//2 - part_size//2:len(text)//2 + part_size//2] +
"\n\n[...省略...]\n\n" +
text[-part_size:]
)
def _generate_simple_timeline(self, content: Content) -> List[Dict[str, Any]]:
"""生成简单时间轴(旧版接口)"""
duration = content.duration or 600
# 短视频分为2-3段
segments = 2 if duration < 300 else 3
segment_duration = duration // segments
timeline = []
for i in range(segments):
start = i * segment_duration
end = min((i + 1) * segment_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_simple_timeline_from_duration(self, title: str, duration: int) -> List[Dict[str, Any]]:
"""生成简单时间轴(新版接口)"""
duration = duration or 600
# 短视频分为2-3段
segments = 2 if duration < 300 else 3
segment_duration = duration // segments
timeline = []
for i in range(segments):
start = i * segment_duration
end = min((i + 1) * segment_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 _format_time(self, seconds: int) -> str:
"""格式化秒数为时间字符串"""
hours = seconds // 3600
minutes = (seconds % 3600) // 60
secs = seconds % 60
if hours > 0:
return f"{hours}:{minutes:02d}:{secs:02d}"
else:
return f"{minutes}:{secs:02d}"
@@ -1,18 +0,0 @@
# Extractors module for obsidian-kb
from .base import Extractor, ExtractionError
from .bilibili_extractor import BilibiliExtractor
from .ocr_processor import OCRProcessor
# 以下模块待实现
# from .subtitle_detector import SubtitleDetector
# from .hard_subtitle_extractor import HardSubtitleExtractor
__all__ = [
'Extractor',
'ExtractionError',
'BilibiliExtractor',
'OCRProcessor',
# 'SubtitleDetector',
# 'HardSubtitleExtractor'
]
-83
View File
@@ -1,83 +0,0 @@
"""
Extractor 抽象基类
"""
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional
class ExtractionError(Exception):
"""提取器异常基类"""
def __init__(self, message: str, source_id: str = "", details: Optional[Dict[str, Any]] = None):
self.message = message
self.source_id = source_id
self.details = details or {}
super().__init__(self.message)
class Extractor(ABC):
"""
内容提取器抽象基类
负责从各种来源(视频、文章等)提取结构化数据
"""
@abstractmethod
def extract(self, source_id: str) -> Dict[str, Any]:
"""
提取源内容
Args:
source_id: 来源唯一标识符(如视频ID、文章URL等)
Returns:
Dict[str, Any]: 包含以下键的字典:
- metadata: Dict[str, Any] - 元数据(标题、作者、时长等)
- stats: Dict[str, Any] - 统计信息(观看次数、点赞数等)
- subtitle: str - 字幕/文本内容
- description: str - 内容描述
Raises:
ExtractionError: 提取失败时抛出
"""
pass
def validate_source_id(self, source_id: str) -> bool:
"""
验证源ID格式是否有效(可选实现)
Args:
source_id: 来源唯一标识符
Returns:
bool: 如果ID格式有效返回True,否则返回False
"""
# 默认实现:非空即有效
return bool(source_id and source_id.strip())
def _handle_error(self, error: Exception, source_id: str, context: Optional[Dict[str, Any]] = None) -> None:
"""
统一错误处理基类方法
Args:
error: 原始异常
source_id: 来源ID
context: 额外的上下文信息
Raises:
ExtractionError: 封装后的提取器异常
"""
error_details = {
'error_type': type(error).__name__,
'error_message': str(error),
'source_id': source_id,
'extractor_class': self.__class__.__name__
}
if context:
error_details.update(context)
raise ExtractionError(
message=f"提取失败: {str(error)}",
source_id=source_id,
details=error_details
) from error
@@ -1,752 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
B站视频内容提取器
继承自 Extractor 基类,使用 Playwright 提取B站视频信息。
支持短链接、BV号、完整URL的解析。
"""
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__)
from playwright.async_api import async_playwright
from bs4 import BeautifulSoup
from .base import Extractor, ExtractionError
# 导入 kb-link-parser 用于短链解析
import sys
import os
# 添加 kb-link-parser 到导入路径
KB_LINK_PARSER_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
sys.path.insert(0, os.path.join(KB_LINK_PARSER_PATH, 'kb-link-parser'))
from link_parser_shortlink import ShortLinkResolver
from .hard_subtitle_extractor import HardSubtitleExtractor
# 尝试导入 stealth 支持
try:
from undetected_playwright import stealth_async
STEALTH_AVAILABLE = True
except ImportError:
STEALTH_AVAILABLE = False
class BilibiliExtractor(Extractor):
"""
B站视频内容提取器
使用 Playwright + stealth 模式提取视频元数据、统计数据和字幕
"""
# B站相关正则模式
BV_PATTERN = re.compile(r'BV[a-zA-Z0-9]{10}')
URL_PATTERN = re.compile(r'https?://www\.bilibili\.com/video/(BV[a-zA-Z0-9]{10})')
def __init__(self, config=None):
"""
初始化提取器
Args:
config: 配置对象(支持 get() 方法)
"""
self.config = config
self._load_config()
self.resolver = ShortLinkResolver()
def _load_config(self):
"""从配置加载参数"""
if self.config:
try:
self.headless = self.config.get('sources.bilibili.extraction.headless', True)
self.timeout = self.config.get('sources.bilibili.extraction.timeout', 30) * 1000 # 转换为毫秒
except:
# 如果配置获取失败,使用默认值
self.headless = True
self.timeout = 30000
else:
self.headless = True
self.timeout = 30000
def extract(self, source_id: str) -> Dict[str, Any]:
"""
提取B站视频信息
Args:
source_id: 来源标识(支持BV号、完整URL、短链接)
Returns:
Dict containing:
- metadata: Dict with title, author, bvid, aid, pubtime, duration, url
- stats: Dict with view, danmaku, reply, favorite, coin, share, like
- subtitle: str - 字幕文本(如果有)
- description: str - 视频简介
Raises:
ExtractionError: 提取失败时抛出
"""
# 验证输入
if not self.validate_source_id(source_id):
raise ExtractionError(f"无效的源ID: {source_id}", source_id=source_id)
# 解析并获取BV号
bvid = self._resolve_bvid(source_id)
# 如果解析失败,检查是否为短链接
if not bvid and self.resolver.is_bilibili_short_link(source_id):
# 使用Playwright解析短链接
try:
bvid = asyncio.run(self._resolve_shortlink_bvid(source_id))
except Exception as e:
raise ExtractionError(f"短链接解析失败: {e}", source_id=source_id)
if not bvid:
raise ExtractionError(f"无法解析BV号: {source_id}", source_id=source_id)
# 使用异步方法提取
try:
result = asyncio.run(self._extract_async(bvid))
except Exception as e:
self._handle_error(e, source_id, {'bvid': bvid, 'step': 'async_extraction'})
return result
def _resolve_bvid(self, source_id: str) -> Optional[str]:
"""
解析源ID,提取BV号
Args:
source_id: BV号、URL或短链接
Returns:
BV号字符串,解析失败返回None
"""
# 1. 直接BV号
bv_match = self.BV_PATTERN.fullmatch(source_id.strip())
if bv_match:
return source_id.strip()
# 2. 完整URL
url_match = self.URL_PATTERN.match(source_id)
if url_match:
return url_match.group(1)
# 3. 短链接处理已移至 extract() 方法中的 _resolve_shortlink_bvid()
# 这里不再处理,避免混淆
return None
async def _resolve_shortlink_bvid(self, shortlink: str) -> Optional[str]:
"""
使用 Playwright 解析B站短链接,获取 BV 号
Args:
shortlink: B站短链接(如 https://b23.tv/xxxxx)
Returns:
BV号字符串,解析失败返回None
"""
async with async_playwright() as playwright:
browser = None
try:
# 启动浏览器(stealth 模式)
browser = await playwright.chromium.launch(
headless=self.headless,
args=[
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-blink-features=AutomationControlled',
'--disable-dev-shm-usage',
'--start-maximized',
]
)
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',
timezone_id='Asia/Shanghai',
)
# 应用 stealth 模式(如果可用)
if STEALTH_AVAILABLE:
await stealth_async(context)
page = await context.new_page()
# 访问短链接(等待重定向完成)
await page.goto(shortlink, wait_until='domcontentloaded', timeout=self.timeout)
await asyncio.sleep(3) # 等待重定向完成
# 获取重定向后的 URL
final_url = page.url
# 从 URL 中提取 BV 号
bv_match = re.search(r'BV[a-zA-Z0-9]{10}', final_url)
if bv_match:
return bv_match.group(0)
except Exception as e:
raise ExtractionError(f"短链接解析异常: {e}", source_id=shortlink)
finally:
if browser:
await browser.close()
return None
async def _extract_async(self, bvid: str) -> Dict[str, Any]:
"""
异步提取视频信息
Args:
bvid: B站视频BV号
Returns:
提取结果字典
"""
url = f"https://www.bilibili.com/video/{bvid}"
result = {
'metadata': {},
'stats': {},
'subtitle': '',
'description': ''
}
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',
'--start-maximized',
]
)
# 创建上下文
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',
timezone_id='Asia/Shanghai',
extra_http_headers={
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Referer': 'https://www.bilibili.com/',
}
)
# 应用 stealth 模式(如果可用)
if STEALTH_AVAILABLE:
await stealth_async(context)
# 创建页面
page = await context.new_page()
# 访问页面
try:
response = await page.goto(url, wait_until='domcontentloaded', timeout=self.timeout)
except Exception:
# 即使 goto 失败,也可能可以访问 API
response = None
# 等待页面加载(即使HTTP状态码不为200也继续,因为可能可以访问API)
await asyncio.sleep(3)
# 获取页面内容(不要强制等待 networkidle,避免超时)
try:
content = await page.content()
except Exception as e:
# 如果获取内容失败,可能是因为页面导航,尝试其他方法
content = ""
# 尝试多种方法提取
video_data = await self._try_extract_methods(page, content, bvid)
if not video_data:
raise ExtractionError("无法提取视频信息", source_id=bvid)
# 组织返回数据
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', '')
# 尝试提取字幕
result['subtitle'] = await self._extract_subtitles(page, bvid)
await browser.close()
except Exception as e:
if 'browser' in locals():
await browser.close()
raise
return result
async def _try_extract_methods(self, page, content: str, bvid: str) -> Dict[str, Any]:
"""
尝试多种方法提取视频信息
Args:
page: Playwright page对象
content: 页面HTML内容
bvid: BV号
Returns:
视频信息字典
"""
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_initial_state(initial_state)
if video_data and video_data.get('title'):
return video_data
except Exception as e:
pass
# 方法2: 从 script 标签提取
try:
soup = BeautifulSoup(content, 'lxml')
script_tags = soup.find_all('script')
for script in script_tags:
if script.string and '__INITIAL_STATE__' in script.string:
match = re.search(r'window\.__INITIAL_STATE__\s*=\s*({.+?});', script.string)
if match:
data = json.loads(match.group(1))
video_data = self._parse_initial_state(data)
if video_data and video_data.get('title'):
return video_data
except Exception as e:
pass
# 方法3: 使用 API
try:
api_url = "https://api.bilibili.com/x/web-interface/view"
response = await page.request.get(api_url, params={'bvid': bvid})
if response.ok:
data = await response.json()
if data.get('code') == 0:
video_data = self._parse_api_response(data.get('data', {}))
if video_data and video_data.get('title'):
return video_data
except Exception as e:
pass
return video_data
def _parse_initial_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 ''
duration = video.get('duration', 0)
video_data['duration'] = self._format_duration(duration)
video_data['desc'] = video.get('desc', '')
tags = []
for tag in video.get('tag', []):
tags.append(tag.get('tag_name', ''))
video_data['tags'] = ', '.join(tags)
except Exception as e:
pass
return video_data
def _parse_api_response(self, data: Dict) -> Dict[str, Any]:
"""解析API响应数据"""
video_data = {}
try:
video_data['title'] = data.get('title', '')
video_data['bvid'] = data.get('bvid', '')
video_data['aid'] = data.get('aid', '')
owner = data.get('owner', {})
video_data['author'] = owner.get('name', '')
video_data['mid'] = owner.get('mid', '')
stat = data.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 = data.get('pubdate', 0)
video_data['pubtime'] = datetime.fromtimestamp(pubdate).strftime('%Y-%m-%d %H:%M:%S') if pubdate else ''
duration = data.get('duration', 0)
video_data['duration'] = self._format_duration(duration)
video_data['desc'] = data.get('desc', '')
tags = []
for tag in data.get('tag', []):
tags.append(tag.get('tag_name', ''))
video_data['tags'] = ', '.join(tags)
except Exception as e:
pass
return video_data
async def _extract_subtitles(self, page, bvid: str) -> str:
"""
提取视频字幕
支持的字幕类型:
1. 官方 CC 字幕(需要 UP 主手动上传)
2. AI 自动生成字幕(标记为 ai-zh)
3. WebVTT 格式字幕
注意:硬字幕(嵌入视频画面内)无法提取
Args:
page: Playwright page对象
bvid: BV号
Returns:
字幕文本(多行字符串)
"""
subtitles = []
try:
# 首先通过 API 获取视频信息(包含 cid 和 aid)
api_url = "https://api.bilibili.com/x/web-interface/view"
response = await page.request.get(api_url, params={'bvid': bvid})
if not response.ok:
return ''
data = await response.json()
if data.get('code') != 0:
return ''
video_data = data.get('data', {})
cid = video_data.get('cid')
aid = video_data.get('aid')
if not cid:
return ''
# 方法1: 使用 WBI API(推荐,支持 AI 字幕)
player_api_url = "https://api.bilibili.com/x/player/wbi/v2"
player_response = await page.request.get(player_api_url, params={'aid': aid, 'cid': cid})
if player_response.ok:
player_data = await player_response.json()
if player_data.get('code') == 0:
subtitle_info = player_data.get('data', {}).get('subtitle', {})
subtitles_list = subtitle_info.get('subtitles', [])
if subtitles_list:
# 优先选择中文字幕(包括 AI 字幕)
subtitle_item = None
for sub in subtitles_list:
# 优先级: ai-zh > zh-CN > zh-Hans > 其他
lan = sub.get('lan', '')
if lan == 'ai-zh':
subtitle_item = sub
break
elif lan in ['zh-CN', 'zh-Hans', 'zh']:
if not subtitle_item:
subtitle_item = sub
# 如果没有找到中文,使用第一个
if not subtitle_item:
subtitle_item = subtitles_list[0]
if subtitle_item:
subtitle_url = subtitle_item.get('subtitle_url', '')
if subtitle_url:
full_url = f"https:{subtitle_url}"
sub_response = await page.request.get(full_url)
if sub_response.ok:
sub_data = await sub_response.json()
subtitles = self._parse_subtitle_json(sub_data)
# 如果找到字幕,直接返回
if subtitles:
lines = []
for sub in subtitles:
lines.append(f"[{sub['from']} - {sub['to']}] {sub['content']}")
return '\n'.join(lines)
# 方法2: 如果 WBI 失败,使用旧版 API
player_api_url = "https://api.bilibili.com/x/player/v2"
player_response = await page.request.get(player_api_url, params={'bvid': bvid, 'cid': cid})
if player_response.ok:
player_data = await player_response.json()
if player_data.get('code') == 0:
subtitle_info = player_data.get('data', {}).get('subtitle', {})
subtitles_list = subtitle_info.get('subtitles', [])
if subtitles_list:
subtitle_item = subtitles_list[0]
subtitle_url = subtitle_item.get('subtitle_url', '')
if subtitle_url:
full_url = f"https:{subtitle_url}"
sub_response = await page.request.get(full_url)
if sub_response.ok:
sub_data = await sub_response.json()
subtitles = self._parse_subtitle_json(sub_data)
except Exception as e:
# 静默失败,不影响视频信息提取
pass
# 如果所有方法都没有找到字幕,尝试硬字幕提取(OCR)
if not subtitles:
try:
# 初始化硬字幕提取器
hard_subtitle_extractor = HardSubtitleExtractor(self.config)
# 尝试从视频流中提取硬字幕
# 注意:这里需要传入 cid,所以先从 page 获取
api_url = "https://api.bilibili.com/x/web-interface/view"
response = await page.request.get(api_url, params={'bvid': bvid})
if response.ok:
data = await response.json()
if data.get('code') == 0:
video_data = data.get('data', {})
cid = video_data.get('cid')
if cid:
# 使用硬字幕提取器
hard_result = hard_subtitle_extractor.extract_subtitle(bvid)
if hard_result and hard_result.get('text'):
logger.info("硬字幕提取成功")
# 格式化结果
hard_text = hard_result.get('text', '')
segments = hard_result.get('segments', [])
lines = []
for seg in segments:
lines.append(f"[{seg['time']:.1f}s] {seg['text']}")
return '\n'.join(lines)
else:
logger.warning("硬字幕提取失败或无结果")
except Exception as e:
logger.warning(f"硬字幕提取异常: {e}")
pass
# 转换为多行字符串
if subtitles:
lines = []
for sub in subtitles:
lines.append(f"[{sub['from']} - {sub['to']}] {sub['content']}")
return '\n'.join(lines)
return ''
def _parse_subtitle_json(self, data: Dict) -> list:
"""解析字幕JSON数据"""
subtitles = []
try:
body = data.get('body', [])
for item in body:
from_time = item.get('from', 0)
to_time = item.get('to', 0)
content = item.get('content', '')
subtitles.append({
'from': self._format_timestamp(from_time),
'to': self._format_timestamp(to_time),
'content': content
})
except Exception as e:
pass
return subtitles
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}"
def _format_timestamp(self, seconds: float) -> str:
"""格式化时间戳"""
minutes = int(seconds // 60)
secs = int(seconds % 60)
return f"{minutes:02d}:{secs:02d}"
# 创建测试脚本的辅助函数
def create_test_script():
"""创建简单的测试脚本"""
script = """#!/usr/bin/env python3
# -*- coding: utf-8 -*-
\"\"\"
B站提取器测试脚本
\"\"\"
import sys
import os
# 添加项目路径
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..'))
from skills.obsidian_kb.src.extractors.bilibili_extractor import BilibiliExtractor
from skills.obsidian_kb.src.config import get_config
def main():
\"\"\"测试提取器\"\"\"
# 获取配置
config = get_config()
# 创建提取器
extractor = BilibiliExtractor(config=config)
# 测试BV号
bvid = "BV1MnFNz5EDL"
print(f"开始提取视频: {bvid}")
print("=" * 60)
try:
result = extractor.extract(bvid)
print("\\n提取成功!\\n")
print("=" * 60)
print("元数据 (metadata):")
print("=" * 60)
for key, value in result['metadata'].items():
print(f" {key}: {value}")
print("\\n" + "=" * 60)
print("统计数据 (stats):")
print("=" * 60)
for key, value in result['stats'].items():
print(f" {key}: {value:,}")
print("\\n" + "=" * 60)
print("简介 (description):")
print("=" * 60)
desc = result['description']
if len(desc) > 500:
print(desc[:500] + "...")
else:
print(desc)
print("\\n" + "=" * 60)
print("字幕 (subtitle):")
print("=" * 60)
subtitle = result['subtitle']
if subtitle:
lines = subtitle.split('\\n')
if len(lines) > 10:
print("\\n".join(lines[:10]))
print(f"\\n...(共 {len(lines)} 条字幕)")
else:
print(subtitle)
else:
print(" (无字幕)")
print("\\n" + "=" * 60)
print("✅ 测试完成")
print("=" * 60)
except Exception as e:
print(f"\\n❌ 测试失败: {e}")
import traceback
traceback.print_exc()
if __name__ == '__main__':
main()
"""
return script
"""
示例用法:
```python
from skills.obsidian_kb.src.extractors.bilibili_extractor import BilibiliExtractor
from skills.obsidian_kb.src.config import get_config
# 获取配置
config = get_config()
# 创建提取器
extractor = BilibiliExtractor(config=config)
# 提取视频信息(支持BV号、URL)
result = extractor.extract('BV1MnFNz5EDL')
# 访问结果
print(result['metadata']['title'])
print(result['stats']['view'])
print(result['description'])
print(result['subtitle'])
```
"""
@@ -1,355 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
OCR 文字识别处理器(EasyOCR 版本)
使用 EasyOCR 实现视频帧文字识别功能,作为 PaddleOCR 的替代方案
主要优势:不依赖 PaddlePaddle,避免 oneDNN 兼容性问题
"""
import re
import numpy as np
from typing import List, Dict, Any, Tuple
import logging
logger = logging.getLogger(__name__)
class EasyOCRProcessor:
"""
EasyOCR 文字识别处理器
使用 EasyOCR 从视频帧中识别文字,支持批量识别和结果过滤
相比 PaddleOCR 的优势:
- 不依赖 PaddlePaddle,避免 oneDNN 兼容性问题
- 安装更简单,依赖更少
- 支持多种语言(70+ 种)
"""
# 相似度阈值:用于判断两个文本是否相似(基于编辑距离)
SIMILARITY_THRESHOLD = 0.85
# 置信度阈值
CONFIDENCE_THRESHOLD = 0.7
# 最小字幕长度(字符数)
MIN_SUBTITLE_LENGTH = 2
def __init__(self, lang: str = 'ch_sim', use_gpu: bool = False, detail: int = 1):
"""
初始化 OCR 处理器
Args:
lang: 语言支持,'ch_sim' 为简体中文,'ch_tra' 为繁体中文
use_gpu: 是否使用 GPU 加速
detail: 识别结果详细程度(0=只返回文本,1=返回文本+坐标+置信度)
"""
self.lang = lang
self.use_gpu = use_gpu
self.detail = detail
# 初始化 EasyOCR 实例
self._ocr = None
self._initialize_ocr()
def _initialize_ocr(self):
"""初始化 EasyOCR 实例"""
try:
from easyocr import Reader
self._ocr = Reader(
[self.lang],
gpu=self.use_gpu,
verbose=False # 不显示详细日志
)
logger.info("EasyOCR 初始化成功")
except Exception as e:
logger.error(f"EasyOCR 初始化失败: {e}")
raise RuntimeError(f"无法初始化 EasyOCR: {e}")
def _initialize_ocr(self):
"""初始化 EasyOCR 实例"""
try:
from easyocr import Reader
self._ocr = Reader(
[self.lang],
gpu=self.use_gpu,
verbose=False # 不显示详细日志
)
logger.info("EasyOCR 初始化成功")
except Exception as e:
logger.error(f"EasyOCR 初始化失败: {e}")
raise RuntimeError(f"无法初始化 EasyOCR: {e}")
def recognize_frames(self, frames: List[Tuple[np.ndarray, float]]) -> List[Dict[str, Any]]:
"""
批量识别帧中的文字
Args:
frames: 帧列表,每个元素为 (图像数组, 时间戳) 元组
- 图像数组:numpy.ndarray,形状为 (H, W, 3) 的 RGB 图像
- 时间戳:float,单位为秒
Returns:
List[Dict[str, Any]]: 识别结果列表,每个结果包含:
- time: float, 时间戳(秒)
- text: str, 识别的文本
- confidence: float, 置信度 (0-1)
"""
if not frames:
logger.warning("输入帧列表为空")
return []
results = []
for image, timestamp in frames:
try:
# 调用 EasyOCR 进行识别
# EasyOCR 返回格式: [[[x1, y1], [x2, y2], [x3, y3], [x4, y4]], (text, confidence, ...)]
# 根据 detail 参数,返回格式不同:
# detail=0: 返回 [text1, text2, ...]
# detail=1: 返回 [[bbox, (text, confidence)], ...]
ocr_result = self._ocr.readtext(image, detail=self.detail)
if ocr_result:
if self.detail == 0:
# 简单模式:只返回文本列表
for i, text in enumerate(ocr_result):
if text:
results.append({
'time': timestamp,
'text': str(text),
'confidence': 1.0 # 简单模式下没有置信度
})
else:
# 详细模式:返回边界框+文本+置信度
for item in ocr_result:
if len(item) >= 2:
bbox = item[0]
text_info = item[1]
if len(text_info) >= 2:
text, confidence = text_info[0], text_info[1]
# 跳过空文本
if not text:
continue
# 处理置信度:可能是数字或特殊字符
try:
if isinstance(confidence, str):
# 如果是 '?' 或其他特殊字符,使用默认置信度
conf_float = 0.5
else:
conf_float = float(confidence)
except (ValueError, TypeError):
conf_float = 0.5
results.append({
'time': timestamp,
'text': text,
'confidence': conf_float
})
except Exception as e:
logger.warning(f"帧识别失败 (时间戳 {timestamp}s): {e}")
continue
logger.info(f"原始识别结果数量: {len(results)}")
# 过滤和清洗结果
filtered_results = self._filter_and_clean(results)
logger.info(f"过滤后结果数量: {len(filtered_results)}")
return filtered_results
def _filter_and_clean(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
过滤和清洗识别结果
Args:
results: 原始识别结果列表
Returns:
List[Dict[str, Any]]: 过滤后的结果列表
"""
if not results:
return []
# 步骤 1: 按置信度过滤
filtered = self._filter_by_confidence(results)
logger.debug(f"置信度过滤后: {len(filtered)}")
# 步骤 2: 过滤噪声(纯符号、单字符等)
filtered = self._filter_noise(filtered)
logger.debug(f"噪声过滤后: {len(filtered)}")
# 步骤 3: 去除重复或相似的文本
filtered = self._remove_duplicates(filtered)
logger.debug(f"去重后: {len(filtered)}")
# 步骤 4: 按时间戳排序
filtered.sort(key=lambda x: x['time'])
return filtered
def _filter_by_confidence(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
按置信度过滤结果
Args:
results: 识别结果列表
Returns:
List[Dict[str, Any]]: 高置信度结果
"""
return [r for r in results if r['confidence'] >= self.CONFIDENCE_THRESHOLD]
def _filter_noise(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
过滤噪声结果
过滤条件:
- 文本长度小于最小长度
- 纯符号或空白字符
Args:
results: 识别结果列表
Returns:
List[Dict[str, Any]]: 过滤后的结果
"""
filtered = []
for result in results:
text = result['text'].strip()
# 过滤过短的文本
if len(text) < self.MIN_SUBTITLE_LENGTH:
continue
# 过滤纯符号或空白字符
if self._is_noise(text):
continue
filtered.append(result)
return filtered
def _is_noise(self, text: str) -> bool:
"""
判断文本是否为噪声
Args:
text: 要判断的文本
Returns:
bool: 如果是噪声返回 True
"""
# 使用正则判断是否只包含符号和空白
try:
# 移除标点符号后检查是否为空
# 先替换常见的中文标点
cleaned = re.sub(r'[^\w\u4e00-\u9fff]', '', text)
return len(cleaned) == 0
except:
# 如果正则出错,保守处理,不算噪声
return False
def _remove_duplicates(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
去除重复或相似的文本
基于编辑距离(Levenshtein distance)判断文本相似度
Args:
results: 识别结果列表(假设已按时间戳排序)
Returns:
List[Dict[str, Any]]: 去重后的结果
"""
if not results:
return []
deduped = [results[0]]
for current in results[1:]:
last = deduped[-1]
# 计算相似度
similarity = self._calculate_similarity(current['text'], last['text'])
# 如果相似度低于阈值,则保留
if similarity < self.SIMILARITY_THRESHOLD:
deduped.append(current)
else:
# 相似文本:保留置信度更高的那个
if current['confidence'] > last['confidence']:
deduped[-1] = current
return deduped
def _calculate_similarity(self, text1: str, text2: str) -> float:
"""
计算两个文本的相似度(基于归一化编辑距离)
Args:
text1: 文本1
text2: 文本2
Returns:
float: 相似度 (0-1),1 表示完全相同
"""
if text1 == text2:
return 1.0
len1, len2 = len(text1), len(text2)
if len1 == 0 or len2 == 0:
return 0.0
# 动态规划计算编辑距离
dp = np.zeros((len1 + 1, len2 + 1))
for i in range(len1 + 1):
dp[i, 0] = i
for j in range(len2 + 1):
dp[0, j] = j
for i in range(1, len1 + 1):
for j in range(1, len2 + 1):
if text1[i-1] == text2[j-1]:
dp[i, j] = dp[i-1, j-1]
else:
dp[i, j] = min(
dp[i-1, j] + 1, # 删除
dp[i, j-1] + 1, # 插入
dp[i-1, j-1] + 1 # 替换
)
edit_distance = dp[len1, len2]
max_len = max(len1, len2)
# 归一化相似度:1 - (编辑距离 / 最大长度)
similarity = 1.0 - (edit_distance / max_len)
return similarity
def get_ocr_info(self) -> Dict[str, Any]:
"""
获取 OCR 处理器信息
Returns:
Dict[str, Any]: OCR 配置信息
"""
return {
'engine': 'easyocr',
'lang': self.lang,
'use_gpu': self.use_gpu,
'detail': self.detail,
'confidence_threshold': self.CONFIDENCE_THRESHOLD,
'similarity_threshold': self.SIMILARITY_THRESHOLD,
'min_subtitle_length': self.MIN_SUBTITLE_LENGTH
}
@@ -1,435 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
硬字幕提取器 - 主协调器
整合 VideoStreamExtractor、SubtitleDetector 和 OCRProcessor,
实现完整的 B站硬字幕提取流程。
"""
import logging
from typing import Dict, Any, List, Optional
from .video_stream_extractor import VideoStreamExtractor
from .subtitle_detector import SubtitleDetector
from .ocr_processor import OCRProcessor
logger = logging.getLogger(__name__)
class HardSubtitleExtractor:
"""
硬字幕提取器(主协调器)
职责:
- 协调视频流提取、字幕区域检测和 OCR 识别
- 实现完整的硬字幕提取流程
- 提供统一的接口返回字幕数据
"""
def __init__(self, config: Optional[Dict] = None):
"""
初始化硬字幕提取器
Args:
config: 配置对象
"""
self.config = config or {}
# 初始化子模块
self.video_extractor = VideoStreamExtractor()
self.subtitle_detector = SubtitleDetector(
cache_size=self._get_config('hard_subtitle.detection.cache_region', 50)
)
self.ocr_processor = OCRProcessor(
use_angle_cls=True,
lang='ch',
use_gpu=self._get_config('hard_subtitle.ocr.use_gpu', False),
show_log=False
)
def _get_config(self, key: str, default: Any = None) -> Any:
"""获取配置值"""
if not self.config:
return default
keys = key.split('.')
value = self.config
for k in keys:
value = value.get(k)
if value is None:
return default
return value
def extract_subtitle(
self,
bvid: str,
interval_seconds: float = None,
max_frames: int = None,
start_time: float = 0.0
) -> Dict[str, Any]:
"""
提取硬字幕
Args:
bvid: B站视频 BV 号
interval_seconds: 提取间隔(秒)
max_frames: 最大帧数
start_time: 开始时间(秒)
Returns:
Dict[str, Any]: 字幕提取结果,包含:
- subtitle_type: "hard_subtitle"
- text: 完整字幕文本
- segments: 字幕分段列表
- confidence: 平均置信度
- source: "paddleocr"
"""
# 使用配置值或默认值
interval_seconds = interval_seconds or self._get_config('hard_subtitle.video.interval_seconds', 5.0)
max_frames = max_frames or self._get_config('hard_subtitle.video.max_frames', 500)
use_download = self._get_config('hard_subtitle.video.stream_mode', True)
logger.info(f"开始提取硬字幕: {bvid}")
logger.info(f"参数: 间隔={interval_seconds}秒, 最大帧数={max_frames}, 开始时间={start_time}秒")
try:
# 步骤1: 提取视频帧
logger.info("步骤1: 提取视频帧...")
frames = self.video_extractor.extract_frames_from_bvid(
bvid=bvid,
interval_seconds=interval_seconds,
max_frames=max_frames,
start_time=start_time,
use_download=use_download
)
if not frames:
logger.warning("未提取到任何视频帧")
return {
'subtitle_type': 'hard_subtitle',
'text': '',
'segments': [],
'confidence': 0.0,
'source': 'paddleocr'
}
logger.info(f"成功提取 {len(frames)} 帧图像")
# 步骤2: 检测字幕区域
logger.info("步骤2: 检测字幕区域...")
use_cache = self._get_config('hard_subtitle.detection.cache_region', True)
subtitle_region = None
# 使用第一帧检测字幕区域
if frames:
first_frame = frames[0]['image']
height, width = first_frame.shape[:2]
# 尝试自动检测
subtitle_region = self.subtitle_detector.auto_detect_subtitle_region(
first_frame,
video_id=bvid,
use_cache=use_cache
)
logger.info(f"字幕区域检测完成: x={subtitle_region[0]}, y={subtitle_region[1]}, "
f"width={subtitle_region[2]}, height={subtitle_region[3]}")
# 如果检测失败,使用预设区域
if not subtitle_region and frames:
height, width = frames[0]['image'].shape[:2]
default_region = self._get_config('hard_subtitle.detection.default_region', 'bottom')
subtitle_region = self.subtitle_detector.get_preset_region(
default_region,
height,
width
)
logger.info(f"使用预设字幕区域: {default_region}")
# 步骤3: 裁剪字幕区域
logger.info("步骤3: 裁剪字幕区域...")
if not subtitle_region:
logger.warning("无法获取字幕区域,跳过裁剪")
# 使用完整帧
cropped_frames = [frame['image'] for frame in frames]
else:
x, y, w, h = subtitle_region
cropped_frames = []
for frame in frames:
image = frame['image']
# 确保坐标在图像范围内
y = min(y, image.shape[0] - 1)
x = min(x, image.shape[1] - 1)
h = min(h, image.shape[0] - y)
w = min(w, image.shape[1] - x)
# 裁剪字幕区域
cropped = image[y:y+h, x:x+w]
cropped_frames.append(cropped)
logger.info(f"裁剪完成: {len(cropped_frames)} 帧字幕区域")
# 步骤4: OCR 识别
logger.info("步骤4: OCR 识别...")
# 准备 OCR 输入格式 (image, timestamp)
ocr_input = [(cropped_frames[i], frames[i]['timestamp'])
for i in range(min(len(cropped_frames), len(frames)))]
ocr_results = self.ocr_processor.recognize_frames(ocr_input)
logger.info(f"OCR 识别完成: {len(ocr_results)} 条结果")
# 步骤5: 整理结果
logger.info("步骤5: 整理字幕结果...")
subtitle_text = self._format_subtitle_text(ocr_results)
segments = ocr_results
confidence = self._calculate_average_confidence(ocr_results)
logger.info(f"平均置信度: {confidence:.3f}")
logger.info(f"字幕文本长度: {len(subtitle_text)} 字符")
return {
'subtitle_type': 'hard_subtitle',
'text': subtitle_text,
'segments': segments,
'confidence': confidence,
'source': 'paddleocr'
}
except Exception as e:
logger.error(f"硬字幕提取失败: {e}", exc_info=True)
# 返回空结果,不阻塞主流程
return {
'subtitle_type': 'hard_subtitle',
'text': '',
'segments': [],
'confidence': 0.0,
'source': 'paddleocr'
}
def _format_subtitle_text(self, ocr_results: List[Dict[str, Any]]) -> str:
"""
格式化字幕文本
Args:
ocr_results: OCR 识别结果列表
Returns:
str: 格式化后的字幕文本
"""
if not ocr_results:
return ''
# 按时间戳排序
sorted_results = sorted(ocr_results, key=lambda x: x['time'])
# 生成字幕文本(带时间戳)
lines = []
for result in sorted_results:
time_str = f"[{result['time']:.1f}s]"
lines.append(f"{time_str} {result['text']}")
return '\n'.join(lines)
def _calculate_average_confidence(self, ocr_results: List[Dict[str, Any]]) -> float:
"""
计算平均置信度
Args:
ocr_results: OCR 识别结果列表
Returns:
float: 平均置信度
"""
if not ocr_results:
return 0.0
confidences = [r.get('confidence', 0.0) for r in ocr_results]
return sum(confidences) / len(confidences) if confidences else 0.0
def extract_subtitle_from_url(
self,
video_url: str,
interval_seconds: float = None,
max_frames: int = None,
start_time: float = 0.0
) -> Dict[str, Any]:
"""
从视频 URL 提取硬字幕
Args:
video_url: 视频流 URL
interval_seconds: 提取间隔(秒)
max_frames: 最大帧数
start_time: 开始时间(秒)
Returns:
Dict[str, Any]: 字幕提取结果
"""
logger.info(f"从 URL 提取硬字幕: {video_url[:100]}...")
# 使用配置值或默认值
interval_seconds = interval_seconds or self._get_config('hard_subtitle.video.interval_seconds', 5.0)
max_frames = max_frames or self._get_config('hard_subtitle.video.max_frames', 500)
try:
# 提取视频帧
frames = self.video_extractor.extract_frames(
video_url=video_url,
interval_seconds=interval_seconds,
max_frames=max_frames,
start_time=start_time
)
if not frames:
logger.warning("未提取到任何视频帧")
return {
'subtitle_type': 'hard_subtitle',
'text': '',
'segments': [],
'confidence': 0.0,
'source': 'paddleocr'
}
# 后续流程与从 BV 号提取相同
return self._process_frames_to_subtitle(frames, video_url)
except Exception as e:
logger.error(f"从 URL 提取字幕失败: {e}", exc_info=True)
return {
'subtitle_type': 'hard_subtitle',
'text': '',
'segments': [],
'confidence': 0.0,
'source': 'paddleocr'
}
def _process_frames_to_subtitle(
self,
frames: List[Dict[str, Any]],
video_id: str
) -> Dict[str, Any]:
"""
处理视频帧到字幕(共享逻辑)
Args:
frames: 帧列表
video_id: 视频 ID
Returns:
Dict[str, Any]: 字幕结果
"""
# 检测字幕区域
use_cache = self._get_config('hard_subtitle.detection.cache_region', True)
subtitle_region = None
if frames:
first_frame = frames[0]['image']
height, width = first_frame.shape[:2]
subtitle_region = self.subtitle_detector.auto_detect_subtitle_region(
first_frame,
video_id=video_id,
use_cache=use_cache
)
# 如果检测失败,使用预设区域
if not subtitle_region and frames:
height, width = frames[0]['image'].shape[:2]
default_region = self._get_config('hard_subtitle.detection.default_region', 'bottom')
subtitle_region = self.subtitle_detector.get_preset_region(
default_region,
height,
width
)
# 裁剪字幕区域
if not subtitle_region:
cropped_frames = [frame['image'] for frame in frames]
else:
x, y, w, h = subtitle_region
cropped_frames = []
for frame in frames:
image = frame['image']
y = min(y, image.shape[0] - 1)
x = min(x, image.shape[1] - 1)
h = min(h, image.shape[0] - y)
w = min(w, image.shape[1] - x)
cropped = image[y:y+h, x:x+w]
cropped_frames.append(cropped)
# OCR 识别
ocr_input = [(cropped_frames[i], frames[i]['timestamp'])
for i in range(min(len(cropped_frames), len(frames)))]
ocr_results = self.ocr_processor.recognize_frames(ocr_input)
# 整理结果
subtitle_text = self._format_subtitle_text(ocr_results)
confidence = self._calculate_average_confidence(ocr_results)
return {
'subtitle_type': 'hard_subtitle',
'text': subtitle_text,
'segments': ocr_results,
'confidence': confidence,
'source': 'paddleocr'
}
# 测试函数
def test_hard_subtitle_extractor():
"""测试硬字幕提取器"""
print("=" * 60)
print("硬字幕提取器测试")
print("=" * 60)
# 创建提取器
extractor = HardSubtitleExtractor()
# 测试视频
test_bvid = "BV1KtFzzqEUG"
print(f"\n测试视频: {test_bvid}")
print("=" * 60)
try:
# 测试1: 提取硬字幕(使用较少帧数进行快速测试)
print("\n【测试1】提取硬字幕...")
print(" 参数: 间隔=5秒, 最大帧数=5, 开始时间=0秒")
result = extractor.extract_subtitle(
bvid=test_bvid,
interval_seconds=5.0,
max_frames=5,
start_time=0.0
)
print(f"\n✅ 提取完成!")
print(f" 字幕类型: {result['subtitle_type']}")
print(f" 字幕来源: {result['source']}")
print(f" 平均置信度: {result['confidence']:.3f}")
print(f" 字幕文本长度: {len(result['text'])} 字符")
print(f" 字幕段落数: {len(result['segments'])}")
# 显示部分字幕文本
if result['text']:
print(f"\n 字幕文本预览 (前 3 段):")
for i, seg in enumerate(result['segments'][:3]):
print(f" [{i+1}] [{seg['time']:.1f}s] {seg['text']}")
if len(result['segments']) > 3:
print(f" ... 还有 {len(result['segments']) - 3} 段")
else:
print(f"\n 字幕文本: (无)")
print("\n" + "=" * 60)
print("✅ 测试通过!")
print("=" * 60)
except Exception as e:
print(f"\n❌ 测试失败: {e}")
import traceback
traceback.print_exc()
if __name__ == '__main__':
test_hard_subtitle_extractor()
@@ -1,309 +0,0 @@
"""
OCR 文字识别处理器
使用 PaddleOCR 实现视频帧文字识别功能,主要用于提取硬字幕
"""
import os
import re
import numpy as np
from typing import List, Dict, Any, Tuple
from paddleocr import PaddleOCR
import logging
logger = logging.getLogger(__name__)
class OCRProcessor:
"""
OCR 文字识别处理器
使用 PaddleOCR 从视频帧中识别文字,支持批量识别和结果过滤
"""
# 相似度阈值:用于判断两个文本是否相似(基于编辑距离)
SIMILARITY_THRESHOLD = 0.85
# 置信度阈值
CONFIDENCE_THRESHOLD = 0.7
# 最小字幕长度(字符数)
MIN_SUBTITLE_LENGTH = 2
# 注意:Python re 模块不支持 \p{P} 等属性类,在 _is_noise 方法中使用自定义逻辑
def __init__(self, use_textline_orientation: bool = True, lang: str = 'ch',
use_gpu: bool = False):
"""
初始化 OCR 处理器
Args:
use_textline_orientation: 是否使用文本行方向分类器(PaddleOCR 3.x 参数名)
lang: 语言支持,'ch' 为中英文混合
use_gpu: 是否使用 GPU 加速(通过环境变量设置,如需启用请先设置 CUDA_VISIBLE_DEVICES)
"""
self.use_textline_orientation = use_textline_orientation
self.lang = lang
self.use_gpu = use_gpu
# 初始化 PaddleOCR 实例
self._ocr = None
self._initialize_ocr()
def _initialize_ocr(self):
"""初始化 PaddleOCR 实例"""
try:
# 强制禁用 oneDNN 和 MKL
os.environ['FLAGS_use_mkldnn'] = '0'
os.environ['MKL_SERVICE_FORCE_INTEL'] = '1'
os.environ['OMP_NUM_THREADS'] = '1'
os.environ['PADDLE_ON_INFER'] = '1'
# PaddleOCR 3.x 版本参数
self._ocr = PaddleOCR(
use_textline_orientation=self.use_textline_orientation,
lang=self.lang
)
logger.info("PaddleOCR 初始化成功(oneDNN 已禁用)")
except Exception as e:
logger.error(f"PaddleOCR 初始化失败: {e}")
raise RuntimeError(f"无法初始化 PaddleOCR: {e}")
def recognize_frames(self, frames: List[Tuple[np.ndarray, float]]) -> List[Dict[str, Any]]:
"""
批量识别帧中的文字
Args:
frames: 帧列表,每个元素为 (图像数组, 时间戳) 元组
- 图像数组:numpy.ndarray,形状为 (H, W, 3) 的 RGB 图像
- 时间戳:float,单位为秒
Returns:
List[Dict[str, Any]]: 识别结果列表,每个结果包含:
- time: float, 时间戳(秒)
- text: str, 识别的文本
- confidence: float, 置信度 (0-1)
"""
if not frames:
logger.warning("输入帧列表为空")
return []
results = []
for image, timestamp in frames:
try:
# 调用 PaddleOCR 进行识别
# 注意:PaddleOCR 3.x 版本的 ocr() 方法参数已简化
ocr_result = self._ocr.ocr(image)
# PaddleOCR 返回格式: [[[box], (text, confidence)], ...]
if ocr_result and ocr_result[0]:
for line in ocr_result[0]:
box, (text, confidence) = line
if text and confidence:
results.append({
'time': timestamp,
'text': text,
'confidence': float(confidence)
})
except Exception as e:
logger.warning(f"帧识别失败 (时间戳 {timestamp}s): {e}")
continue
logger.info(f"原始识别结果数量: {len(results)}")
# 过滤和清洗结果
filtered_results = self._filter_and_clean(results)
logger.info(f"过滤后结果数量: {len(filtered_results)}")
return filtered_results
def _filter_and_clean(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
过滤和清洗识别结果
Args:
results: 原始识别结果列表
Returns:
List[Dict[str, Any]]: 过滤后的结果列表
"""
if not results:
return []
# 步骤 1: 按置信度过滤
filtered = self._filter_by_confidence(results)
logger.debug(f"置信度过滤后: {len(filtered)}")
# 步骤 2: 过滤噪声(纯符号、单字符等)
filtered = self._filter_noise(filtered)
logger.debug(f"噪声过滤后: {len(filtered)}")
# 步骤 3: 去除重复或相似的文本
filtered = self._remove_duplicates(filtered)
logger.debug(f"去重后: {len(filtered)}")
# 步骤 4: 按时间戳排序
filtered.sort(key=lambda x: x['time'])
return filtered
def _filter_by_confidence(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
按置信度过滤结果
Args:
results: 识别结果列表
Returns:
List[Dict[str, Any]]: 高置信度结果
"""
return [r for r in results if r['confidence'] >= self.CONFIDENCE_THRESHOLD]
def _filter_noise(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
过滤噪声结果
过滤条件:
- 文本长度小于最小长度
- 纯符号或空白字符
Args:
results: 识别结果列表
Returns:
List[Dict[str, Any]]: 过滤后的结果
"""
filtered = []
for result in results:
text = result['text'].strip()
# 过滤过短的文本
if len(text) < self.MIN_SUBTITLE_LENGTH:
continue
# 过滤纯符号或空白字符
if self._is_noise(text):
continue
filtered.append(result)
return filtered
def _is_noise(self, text: str) -> bool:
"""
判断文本是否为噪声
Args:
text: 要判断的文本
Returns:
bool: 如果是噪声返回 True
"""
# 使用正则判断是否只包含符号和空白
# 注意:这里用简单的正则,更复杂的判断可以后续优化
try:
# 移除标点符号后检查是否为空
# 先替换常见的中文标点
cleaned = re.sub(r'[^\w\u4e00-\u9fff]', '', text)
return len(cleaned) == 0
except:
# 如果正则出错,保守处理,不算噪声
return False
def _remove_duplicates(self, results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
去除重复或相似的文本
基于编辑距离(Levenshtein distance)判断文本相似度
Args:
results: 识别结果列表(假设已按时间戳排序)
Returns:
List[Dict[str, Any]]: 去重后的结果
"""
if not results:
return []
deduped = [results[0]]
for current in results[1:]:
last = deduped[-1]
# 计算相似度
similarity = self._calculate_similarity(current['text'], last['text'])
# 如果相似度低于阈值,则保留
if similarity < self.SIMILARITY_THRESHOLD:
deduped.append(current)
else:
# 相似文本:保留置信度更高的那个
if current['confidence'] > last['confidence']:
deduped[-1] = current
return deduped
def _calculate_similarity(self, text1: str, text2: str) -> float:
"""
计算两个文本的相似度(基于归一化编辑距离)
Args:
text1: 文本1
text2: 文本2
Returns:
float: 相似度 (0-1),1 表示完全相同
"""
if text1 == text2:
return 1.0
len1, len2 = len(text1), len(text2)
if len1 == 0 or len2 == 0:
return 0.0
# 动态规划计算编辑距离
dp = np.zeros((len1 + 1, len2 + 1))
for i in range(len1 + 1):
dp[i, 0] = i
for j in range(len2 + 1):
dp[0, j] = j
for i in range(1, len1 + 1):
for j in range(1, len2 + 1):
if text1[i-1] == text2[j-1]:
dp[i, j] = dp[i-1, j-1]
else:
dp[i, j] = min(
dp[i-1, j] + 1, # 删除
dp[i, j-1] + 1, # 插入
dp[i-1, j-1] + 1 # 替换
)
edit_distance = dp[len1, len2]
max_len = max(len1, len2)
# 归一化相似度:1 - (编辑距离 / 最大长度)
similarity = 1.0 - (edit_distance / max_len)
return similarity
def get_ocr_info(self) -> Dict[str, Any]:
"""
获取 OCR 处理器信息
Returns:
Dict[str, Any]: OCR 配置信息
"""
return {
'use_textline_orientation': self.use_textline_orientation,
'lang': self.lang,
'use_gpu': self.use_gpu,
'confidence_threshold': self.CONFIDENCE_THRESHOLD,
'similarity_threshold': self.SIMILARITY_THRESHOLD,
'min_subtitle_length': self.MIN_SUBTITLE_LENGTH
}
@@ -1,383 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
字幕区域检测器
用于自动检测B站视频中硬字幕的位置区域。
支持预设区域定义、自动检测和结果缓存。
"""
import cv2
import numpy as np
from typing import Dict, Tuple, Optional, List
from collections import OrderedDict
class SubtitleDetector:
"""
字幕区域检测器
功能:
1. 预设区域定义(底部、中部、顶部)
2. 自动字幕区域检测(基于OpenCV)
3. 检测结果缓存机制
"""
# 预设区域名称
REGION_BOTTOM = "bottom"
REGION_CENTER = "center"
REGION_TOP = "top"
# 预设区域参数(相对于视频高度的比例)
PRESET_REGIONS = {
REGION_BOTTOM: {
"top_ratio": 0.80, # 底部20%
"bottom_ratio": 1.0, # 到底部
"left_ratio": 0.1, # 左右各留10%
"right_ratio": 0.9 # 避免字幕在边缘
},
REGION_CENTER: {
"top_ratio": 0.40, # 中部区域
"bottom_ratio": 0.60,
"left_ratio": 0.1,
"right_ratio": 0.9
},
REGION_TOP: {
"top_ratio": 0.0, # 顶部区域
"bottom_ratio": 0.20,
"left_ratio": 0.1,
"right_ratio": 0.9
}
}
def __init__(self, cache_size: int = 50):
"""
初始化字幕检测器
Args:
cache_size: 缓存最大容量(默认50个视频)
"""
self.cache = OrderedDict()
self.cache_size = cache_size
# 检测参数
self.min_region_height = 0.05 # 最小区域高度(5%)
self.max_region_height = 0.25 # 最大区域高度(25%)
self.text_density_threshold = 0.005 # 文字密度阈值
def get_preset_region(self, region_name: str, frame_height: int,
frame_width: int) -> Tuple[int, int, int, int]:
"""
获取预设区域的坐标
Args:
region_name: 区域名称(bottom/center/top)
frame_height: 帧高度
frame_width: 帧宽度
Returns:
Tuple[int, int, int, int]: (x, y, width, height)
Raises:
ValueError: 无效的区域名称
"""
if region_name not in self.PRESET_REGIONS:
raise ValueError(f"无效的区域名称: {region_name}. 可选值: {list(self.PRESET_REGIONS.keys())}")
params = self.PRESET_REGIONS[region_name]
x = int(frame_width * params["left_ratio"])
y = int(frame_height * params["top_ratio"])
width = int(frame_width * (params["right_ratio"] - params["left_ratio"]))
height = int(frame_height * (params["bottom_ratio"] - params["top_ratio"]))
return (x, y, width, height)
def auto_detect_subtitle_region(self, frame: np.ndarray,
video_id: Optional[str] = None,
use_cache: bool = True) -> Tuple[int, int, int, int]:
"""
自动检测字幕区域
Args:
frame: 视频帧(numpy数组,BGR格式)
video_id: 视频ID(用于缓存,如BV号)
use_cache: 是否使用缓存
Returns:
Tuple[int, int, int, int]: (x, y, width, height) 字幕区域坐标
"""
# 检查缓存
if use_cache and video_id and video_id in self.cache:
cached_region = self.cache[video_id]
# 更新缓存顺序(LRU)
self.cache.move_to_end(video_id)
return cached_region
# 执行自动检测
detected_region = self._detect_text_region(frame)
# 缓存结果
if video_id:
self._update_cache(video_id, detected_region)
return detected_region
def _detect_text_region(self, frame: np.ndarray) -> Tuple[int, int, int, int]:
"""
使用OpenCV检测文字密集区域
策略:
1. 预处理:灰度化、二值化
2. 使用MSER检测文字候选区域
3. 统计区域分布,找到最可能的字幕区域
Args:
frame: 输入帧(BGR格式)
Returns:
Tuple[int, int, int, int]: (x, y, width, height) 检测到的字幕区域
"""
height, width = frame.shape[:2]
# 尝试MSER检测
try:
mser = cv2.MSER_create(
_delta=5,
_min_area=100,
_max_area=int(width * height * 0.05),
_max_variation=0.25,
_min_diversity=0.2
)
# 转换为灰度图
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# MSER检测
regions, _ = mser.detectRegions(gray)
if regions and len(regions) > 5: # 有足够的检测区域
# 计算所有区域的质心和边界框
bboxes = [cv2.boundingRect(region.astype(int)) for region in regions]
# 过滤:只保留白色/黄色文字区域(B站字幕常见颜色)
filtered_bboxes = self._filter_by_color(frame, bboxes)
if filtered_bboxes:
# 分析区域分布
region = self._analyze_region_distribution(filtered_bboxes, width, height)
if region:
return region
except Exception as e:
# MSER失败,尝试备用方法
pass
# 备用方法:边缘检测 + 连通域
return self._detect_by_edges(frame)
def _filter_by_color(self, frame: np.ndarray,
bboxes: List[Tuple[int, int, int, int]]) -> List[Tuple[int, int, int, int]]:
"""
根据颜色过滤候选区域
B站字幕通常是白色或黄色
Args:
frame: 原始帧(BGR格式)
bboxes: 边界框列表
Returns:
过滤后的边界框列表
"""
filtered = []
for x, y, w, h in bboxes:
if w < 20 or h < 10: # 太小的区域跳过
continue
# 提取区域
roi = frame[y:y+h, x:x+w]
# 计算平均颜色
mean_color = cv2.mean(roi)[:3] # BGR
# 判断是否接近白色或黄色
# 白色:B、G、R都较高
# 黄色:R和G高,B低
is_white = all(c > 180 for c in mean_color)
is_yellow = (mean_color[2] > 180 and mean_color[1] > 180 and mean_color[0] < 150)
if is_white or is_yellow:
filtered.append((x, y, w, h))
return filtered
def _analyze_region_distribution(self, bboxes: List[Tuple[int, int, int, int]],
width: int, height: int) -> Tuple[int, int, int, int]:
"""
分析区域分布,找到最可能的字幕区域
Args:
bboxes: 边界框列表
width: 帧宽度
height: 帧高度
Returns:
Tuple[int, int, int, int]: (x, y, width, height) 字幕区域
"""
if not bboxes:
return self.get_preset_region(self.REGION_BOTTOM, height, width)
# 将区域按垂直位置分组
bottom_regions = []
for x, y, w, h in bboxes:
# 计算区域的中心y坐标
center_y = y + h // 2
relative_y = center_y / height
# 如果在底部60%的区域,可能是字幕
if relative_y > 0.4:
bottom_regions.append((x, y, w, h))
if not bottom_regions:
# 如果没有找到底部区域,使用所有区域
bottom_regions = bboxes
# 计算合并区域
if bottom_regions:
min_x = min(box[0] for box in bottom_regions)
max_x = max(box[0] + box[2] for box in bottom_regions)
min_y = min(box[1] for box in bottom_regions)
max_y = max(box[1] + box[3] for box in bottom_regions)
region_width = max_x - min_x
region_height = max_y - min_y
# 扩展区域以包含可能的文字
margin_x = int(width * 0.02)
margin_y = int(height * 0.01)
x = max(0, min_x - margin_x)
y = max(0, min_y - margin_y)
region_width = min(width - x, region_width + 2 * margin_x)
region_height = min(height - y, region_height + 2 * margin_y)
# 确保区域高度在合理范围内
min_height = int(height * self.min_region_height)
max_height = int(height * self.max_region_height)
if region_height < min_height:
region_height = min_height
elif region_height > max_height:
region_height = max_height
return (x, y, region_width, region_height)
# 默认返回底部区域
return self.get_preset_region(self.REGION_BOTTOM, height, width)
def _detect_by_edges(self, frame: np.ndarray) -> Tuple[int, int, int, int]:
"""
备用检测方法:使用边缘检测
Args:
frame: 输入帧
Returns:
Tuple[int, int, int, int]: (x, y, width, height) 字幕区域
"""
height, width = frame.shape[:2]
# 转换为灰度图
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# 边缘检测
edges = cv2.Canny(gray, 50, 150, apertureSize=3)
# 形态学操作:水平线增强
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 1))
horizontal_edges = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel)
# 找到水平边缘密集的区域
bottom_part = horizontal_edges[int(height * 0.5):, :]
# 统计每行的边缘数量
row_counts = np.sum(bottom_part, axis=1)
# 找到边缘密集的行
if len(row_counts) > 0:
threshold = np.max(row_counts) * 0.3
text_rows = np.where(row_counts > threshold)[0]
if len(text_rows) > 0:
# 计算字幕区域
start_row = text_rows[0] + int(height * 0.5)
end_row = text_rows[-1] + int(height * 0.5)
region_height = end_row - start_row
# 扩展区域
margin = int(height * 0.02)
y = max(int(height * 0.7), start_row - margin)
region_height = min(int(height * 0.25), region_height + 2 * margin)
# 确保不超出帧边界
if y + region_height > height:
region_height = height - y
x = int(width * 0.1)
region_width = int(width * 0.8)
return (x, y, region_width, region_height)
# 默认返回底部区域
return self.get_preset_region(self.REGION_BOTTOM, height, width)
def _update_cache(self, video_id: str, region: Tuple[int, int, int, int]):
"""
更新缓存
Args:
video_id: 视频ID
region: 检测到的区域
"""
# 如果缓存已满,删除最旧的条目
if len(self.cache) >= self.cache_size:
self.cache.popitem(last=False)
# 添加新条目
self.cache[video_id] = region
def clear_cache(self, video_id: Optional[str] = None):
"""
清空缓存
Args:
video_id: 如果指定,只删除该视频的缓存;否则清空所有缓存
"""
if video_id:
if video_id in self.cache:
del self.cache[video_id]
else:
self.cache.clear()
def get_cached_regions(self) -> Dict[str, Tuple[int, int, int, int]]:
"""
获取所有缓存区域
Returns:
Dict[video_id, region]: 缓存的区域字典
"""
return dict(self.cache)
def get_cache_size(self) -> int:
"""
获取当前缓存大小
Returns:
int: 缓存中的条目数
"""
return len(self.cache)
@@ -1,517 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
B站硬字幕提取器 - 视频流提取模块
从B站获取视频流URL并提取视频帧用于硬字幕识别
"""
import json
import time
from typing import List, Dict, Any, Optional, Tuple
from urllib.request import Request, urlopen
from urllib.parse import urlencode
try:
import cv2
OPENCV_AVAILABLE = True
except ImportError:
OPENCV_AVAILABLE = False
class VideoStreamExtractor:
"""
视频流提取器
负责从B站获取视频流URL并提取视频帧
"""
BASE_URL = "https://api.bilibili.com"
def __init__(self):
"""
初始化提取器
"""
self._img_key = None
self._sub_key = None
def _get_headers(self) -> Dict[str, str]:
"""
获取请求头
Returns:
Dict[str, str]: 请求头字典
"""
return {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'https://www.bilibili.com',
'Accept': 'application/json, text/plain, */*',
}
def _fetch_json(self, url: str, params: Optional[Dict] = None) -> Optional[Dict]:
"""
发送HTTP GET请求并返回JSON数据
Args:
url: 请求URL
params: 查询参数
Returns:
Optional[Dict]: JSON数据或None
"""
try:
if params:
query = urlencode(params)
url = f"{url}?{query}"
req = Request(url, headers=self._get_headers())
with urlopen(req, timeout=30) as response:
return json.loads(response.read().decode('utf-8'))
except Exception as e:
print(f"请求失败 {url}: {e}")
return None
def get_video_info(self, bvid: str) -> Optional[Dict[str, Any]]:
"""
获取视频基本信息(包含cid)
Args:
bvid: B站视频BV号
Returns:
Optional[Dict]: 视频信息字典,包含cid、aid等信息
"""
api_url = f"{self.BASE_URL}/x/web-interface/view"
data = self._fetch_json(api_url, {'bvid': bvid})
if not data or data.get('code') != 0:
print(f"获取视频信息失败: {data}")
return None
return data.get('data', {})
def get_video_stream_url(
self,
bvid: str,
quality: int = 80,
cid: Optional[str] = None
) -> Optional[str]:
"""
获取视频流URL
Args:
bvid: B站视频BV号
quality: 视频质量 (默认80,表示高清)
cid: 视频分P的CID(可选,如果不提供则自动获取)
Returns:
Optional[str]: 视频流URL,失败返回None
"""
# 如果没有提供cid,先获取视频信息
if not cid:
video_info = self.get_video_info(bvid)
if not video_info:
return None
cid = video_info.get('cid')
if not cid:
print(f"无法获取视频cid: {bvid}")
return None
# 调用播放地址API
playurl_api = f"{self.BASE_URL}/x/player/playurl"
params = {
'bvid': bvid,
'cid': cid,
'qn': quality,
'otype': 'json',
'fnver': 0,
'fnval': 0,
}
data = self._fetch_json(playurl_api, params)
if not data or data.get('code') != 0:
print(f"获取播放地址失败: {data}")
return None
# 从durl列表获取第一个视频流URL
durl_list = data.get('data', {}).get('durl', [])
if not durl_list:
print(f"未找到视频流URL: {bvid}")
return None
# 返回第一个分片的URL
stream_url = durl_list[0].get('url')
if not stream_url:
print(f"视频流URL为空: {bvid}")
return None
# 处理相对URL(如果有)
if stream_url.startswith('//'):
stream_url = 'https:' + stream_url
elif stream_url.startswith('/'):
stream_url = 'https://www.bilibili.com' + stream_url
return stream_url
def extract_frames(
self,
video_url: str,
interval_seconds: float = 5.0,
max_frames: int = 500,
start_time: float = 0.0,
use_download: bool = True
) -> List[Dict[str, Any]]:
"""
从视频流中提取帧
Args:
video_url: 视频流URL
interval_seconds: 提取间隔(秒),默认5秒
max_frames: 最大帧数限制,默认500帧
start_time: 开始时间(秒),默认0秒
use_download: 是否先下载视频(默认True,因为OpenCV无法直接打开HTTPS流)
Returns:
List[Dict[str, Any]]: 帧列表,每帧包含:
- timestamp: 时间戳(秒)
- frame_id: 帧编号
- image: 图像数据(numpy数组)
"""
if not OPENCV_AVAILABLE:
raise RuntimeError("OpenCV未安装,请安装opencv-python: pip install opencv-python")
import tempfile
import os
frames = []
temp_file = None
cap = None
try:
# 如果是URL且需要下载,先下载到临时文件
if use_download and (video_url.startswith('http://') or video_url.startswith('https://')):
temp_file = tempfile.NamedTemporaryFile(suffix='.mp4', delete=False)
temp_path = temp_file.name
temp_file.close()
print(f"视频为URL,先下载到临时文件: {temp_path}")
if not self._download_video(video_url, temp_path):
raise RuntimeError("视频下载失败")
video_source = temp_path
else:
video_source = video_url
# 打开视频流
cap = cv2.VideoCapture(video_source)
if not cap.isOpened():
raise RuntimeError(f"无法打开视频流: {video_source}")
# 获取视频信息
fps = cap.get(cv2.CAP_PROP_FPS)
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
duration = total_frames / fps if fps > 0 else 0
print(f"视频信息: FPS={fps}, 总帧数={total_frames}, 时长={duration:.2f}秒")
# 如果无法获取FPS,使用默认值
if fps <= 0:
fps = 30.0
print(f"无法获取FPS,使用默认值: {fps}")
# 计算帧间隔
frame_interval = int(fps * interval_seconds)
print(f"提取间隔: {interval_seconds}秒 ({frame_interval}帧)")
# 跳转到起始位置
start_frame = int(fps * start_time)
if start_frame > 0:
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
frame_count = 0
extracted_count = 0
while frame_count < max_frames:
# 读取帧
ret, frame = cap.read()
if not ret:
print("已到达视频末尾或读取失败")
break
# 获取当前帧位置
current_frame = int(cap.get(cv2.CAP_PROP_POS_FRAMES))
current_time = current_frame / fps
# 检查是否达到提取间隔
if (current_frame - start_frame) % frame_interval == 0:
frames.append({
'timestamp': current_time,
'frame_id': extracted_count,
'image': frame.copy()
})
extracted_count += 1
print(f"提取帧 {extracted_count}: 时间={current_time:.2f}秒, 帧号={current_frame}")
# 检查是否达到最大帧数
if len(frames) >= max_frames:
break
frame_count += 1
except Exception as e:
print(f"提取帧时出错: {e}")
raise
finally:
# 释放资源
if cap is not None:
cap.release()
# 删除临时文件
if temp_file and os.path.exists(temp_path):
try:
os.unlink(temp_path)
print(f"已删除临时文件: {temp_path}")
except Exception as e:
print(f"删除临时文件失败: {e}")
print(f"提取完成: 共提取 {len(frames)} 帧")
return frames
def _download_video(self, video_url: str, output_path: str, timeout: int = 300) -> bool:
"""
下载视频到本地文件
Args:
video_url: 视频流URL
output_path: 输出文件路径
timeout: 超时时间(秒)
Returns:
bool: 下载成功返回True
"""
import os
try:
print(f"开始下载视频...")
req = Request(video_url, headers=self._get_headers())
with urlopen(req, timeout=timeout) as response:
total_size = int(response.headers.get('content-length', 0))
downloaded = 0
with open(output_path, 'wb') as f:
while True:
chunk = response.read(8192)
if not chunk:
break
f.write(chunk)
downloaded += len(chunk)
if total_size > 0:
progress = (downloaded / total_size) * 100
print(f"下载进度: {progress:.1f}% ({downloaded}/{total_size} bytes)", end='\r')
print(f"\n下载完成: {output_path}")
return True
except Exception as e:
print(f"\n下载失败: {e}")
return False
def extract_frames_from_bvid(
self,
bvid: str,
interval_seconds: float = 5.0,
max_frames: int = 500,
start_time: float = 0.0,
quality: int = 80,
use_download: bool = True
) -> List[Dict[str, Any]]:
"""
从B站视频BV号提取帧(便捷方法)
Args:
bvid: B站视频BV号
interval_seconds: 提取间隔(秒)
max_frames: 最大帧数
start_time: 开始时间(秒)
quality: 视频质量
use_download: 是否先下载视频(默认True)
Returns:
List[Dict[str, Any]]: 帧列表
"""
print(f"开始处理视频: {bvid}")
# 获取视频流URL
print("步骤1: 获取视频流URL...")
stream_url = self.get_video_stream_url(bvid, quality=quality)
if not stream_url:
raise RuntimeError(f"无法获取视频流URL: {bvid}")
print(f"视频流URL: {stream_url[:100]}...")
# 提取帧
print("\n步骤2: 提取视频帧...")
frames = self.extract_frames(
stream_url,
interval_seconds=interval_seconds,
max_frames=max_frames,
start_time=start_time,
use_download=use_download
)
return frames
def save_frame(self, frame: Dict[str, Any], output_path: str) -> bool:
"""
保存单帧为图片文件
Args:
frame: 帧数据字典
output_path: 输出文件路径
Returns:
bool: 保存成功返回True
"""
if not OPENCV_AVAILABLE:
raise RuntimeError("OpenCV未安装")
try:
cv2.imwrite(output_path, frame['image'])
return True
except Exception as e:
print(f"保存帧失败: {e}")
return False
def save_frames(
self,
frames: List[Dict[str, Any]],
output_dir: str,
prefix: str = "frame"
) -> List[str]:
"""
批量保存帧为图片文件
Args:
frames: 帧列表
output_dir: 输出目录
prefix: 文件名前缀
Returns:
List[str]: 保存成功的文件路径列表
"""
import os
if not os.path.exists(output_dir):
os.makedirs(output_dir)
saved_files = []
for frame in frames:
timestamp_str = f"{frame['timestamp']:.2f}".replace('.', '_')
filename = f"{prefix}_{frame['frame_id']:04d}_t{timestamp_str}.jpg"
output_path = os.path.join(output_dir, filename)
if self.save_frame(frame, output_path):
saved_files.append(output_path)
print(f"已保存: {output_path}")
return saved_files
# 测试函数
def test_extractor():
"""测试VideoStreamExtractor"""
print("=" * 60)
print("B站视频流提取器测试")
print("=" * 60)
# 创建提取器
extractor = VideoStreamExtractor()
# 测试视频BV号
test_bvid = "BV1KtFzzqEUG"
print(f"\n测试视频: {test_bvid}")
print("=" * 60)
try:
# 测试1: 获取视频流URL
print("\n【测试1】获取视频流URL...")
stream_url = extractor.get_video_stream_url(test_bvid)
if stream_url:
print(f"✅ 成功获取视频流URL")
print(f" URL长度: {len(stream_url)} 字符")
print(f" URL前缀: {stream_url[:100]}...")
else:
print("❌ 获取视频流URL失败")
return
# 测试2: 提取帧(限制为10帧,间隔5秒)
print("\n【测试2】提取视频帧...")
print(f" 参数: 间隔=5秒, 最大帧数=10, 开始时间=0秒")
frames = extractor.extract_frames(
stream_url,
interval_seconds=5.0,
max_frames=10,
start_time=0.0,
use_download=True
)
print(f"✅ 提取完成: 共提取 {len(frames)} 帧")
# 显示提取的帧信息
if frames:
print("\n帧信息预览:")
print("-" * 60)
for i, frame in enumerate(frames[:5]): # 只显示前5帧
timestamp = frame['timestamp']
frame_id = frame['frame_id']
height, width = frame['image'].shape[:2]
print(f" 帧 {frame_id}: 时间={timestamp:.2f}秒, 尺寸={width}x{height}")
if len(frames) > 5:
print(f" ... 还有 {len(frames) - 5} 帧")
# 测试3: 从BV号直接提取(便捷方法)
print("\n【测试3】从BV号直接提取(便捷方法)...")
frames2 = extractor.extract_frames_from_bvid(
test_bvid,
interval_seconds=5.0,
max_frames=10,
start_time=0.0
)
print(f"✅ 提取完成: 共提取 {len(frames2)} 帧")
# 测试4: 保存部分帧
if frames:
print("\n【测试4】保存前3帧到临时目录...")
import tempfile
temp_dir = tempfile.mkdtemp(prefix="bilibili_frames_")
print(f" 临时目录: {temp_dir}")
saved_files = extractor.save_frames(frames[:3], temp_dir, prefix="test")
print(f"✅ 保存完成: {len(saved_files)} 个文件")
# 显示保存的文件
for f in saved_files:
print(f" {f}")
print("\n" + "=" * 60)
print("✅ 所有测试通过!")
print("=" * 60)
except Exception as e:
print(f"\n❌ 测试失败: {e}")
import traceback
traceback.print_exc()
if __name__ == '__main__':
test_extractor()
-195
View File
@@ -1,195 +0,0 @@
"""Obsidian KB - Git 同步"""
import os
import subprocess
from pathlib import Path
from typing import Optional, List
from datetime import datetime
from .config import get_config
class GitSync:
"""Git仓库同步管理"""
def __init__(self, repo_path: Optional[str] = None):
config = get_config()
self.repo_path = repo_path or config.obsidian_path
self.git_url = config.git_repo
self.git_token = config.git_token
def _run_git(self, args: List[str], cwd: Optional[str] = None) -> tuple[int, str, str]:
"""运行git命令"""
cmd = ['git'] + args
try:
result = subprocess.run(
cmd,
cwd=cwd or self.repo_path,
capture_output=True,
text=True,
encoding='utf-8'
)
return result.returncode, result.stdout, result.stderr
except Exception as e:
return -1, "", str(e)
def is_git_repo(self) -> bool:
"""检查是否为git仓库"""
git_dir = os.path.join(self.repo_path, '.git')
return os.path.isdir(git_dir)
def init_repo(self) -> bool:
"""初始化git仓库"""
if self.is_git_repo():
return True
os.makedirs(self.repo_path, exist_ok=True)
# git init
code, _, _ = self._run_git(['init'])
if code != 0:
return False
# 配置git用户(使用默认值)
self._run_git(['config', 'user.email', 'obsidian-kb@localhost'])
self._run_git(['config', 'user.name', 'Obsidian KB'])
# 添加remote
if self.git_url:
# 如果提供了token,构建带认证的URL
if self.git_token:
auth_url = self._build_auth_url(self.git_url, self.git_token)
self._run_git(['remote', 'add', 'origin', auth_url])
else:
self._run_git(['remote', 'add', 'origin', self.git_url])
return True
def _build_auth_url(self, url: str, token: str) -> str:
"""构建带认证的git URL"""
if url.startswith('https://github.com/'):
return url.replace('https://', f'https://x-access-token:{token}@')
elif url.startswith('https://'):
return url.replace('https://', f'https://oauth2:{token}@')
return url
def add_files(self, files: List[str]) -> bool:
"""添加文件到暂存区"""
if not files:
return True
# 添加所有指定文件
for file_path in files:
# 转换为相对路径
rel_path = os.path.relpath(file_path, self.repo_path)
code, _, _ = self._run_git(['add', rel_path])
if code != 0:
return False
return True
def add_all(self) -> bool:
"""添加所有变更"""
code, _, _ = self._run_git(['add', '.'])
return code == 0
def has_changes(self) -> bool:
"""检查是否有未提交的变更"""
code, stdout, _ = self._run_git(['status', '--porcelain'])
if code != 0:
return False
return bool(stdout.strip())
def commit(self, message: Optional[str] = None, content_title: Optional[str] = None) -> bool:
"""提交变更"""
if not self.has_changes():
return True
if not message:
message = self._generate_commit_message(content_title)
code, _, _ = self._run_git(['commit', '-m', message])
return code == 0
def _generate_commit_message(self, content_title: Optional[str] = None) -> str:
"""生成提交信息"""
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M')
if content_title:
return f"Add: {content_title[:50]}" + ("..." if len(content_title) > 50 else "") + f" [{timestamp}]"
return f"Update KB notes [{timestamp}]"
def push(self, branch: str = 'main') -> bool:
"""推送到远程"""
# 检查是否有origin
code, stdout, _ = self._run_git(['remote'])
if code != 0 or 'origin' not in stdout:
return False
# 检查分支是否存在,不存在则创建
code, stdout, _ = self._run_git(['branch', '--show-current'])
current_branch = stdout.strip()
if not current_branch:
# 创建初始分支
self._run_git(['checkout', '-b', branch])
# 推送
code, _, _ = self._run_git(['push', '-u', 'origin', branch])
return code == 0
def pull(self, branch: str = 'main') -> bool:
"""从远程拉取"""
code, _, _ = self._run_git(['pull', 'origin', branch])
return code == 0
def sync(self, files: Optional[List[str]] = None, content_title: Optional[str] = None) -> tuple[bool, str]:
"""完整同步流程"""
# 初始化仓库
if not self.init_repo():
return False, "Failed to initialize git repository"
# 添加文件
if files:
if not self.add_files(files):
return False, "Failed to add files"
else:
if not self.add_all():
return False, "Failed to add files"
# 提交
if not self.commit(content_title=content_title):
if not self.has_changes():
return True, "No changes to commit"
return False, "Failed to commit"
# 推送
if self.git_url and not self.push():
return False, "Failed to push"
return True, "Sync completed successfully"
def get_status(self) -> dict:
"""获取仓库状态"""
status = {
'is_repo': self.is_git_repo(),
'has_changes': False,
'branch': '',
'remote': ''
}
if not status['is_repo']:
return status
status['has_changes'] = self.has_changes()
_, stdout, _ = self._run_git(['branch', '--show-current'])
status['branch'] = stdout.strip()
_, stdout, _ = self._run_git(['remote', 'get-url', 'origin'])
url = stdout.strip()
# 隐藏token
if '@' in url:
url = 'https://' + url.split('@', 1)[1]
status['remote'] = url
return status
-228
View File
@@ -1,228 +0,0 @@
"""Obsidian KB - 轻量扫描器
C4.1: 实现轻量扫描,生成一句话摘要、关键词和质量评分
"""
import re
from typing import List, Tuple
from src.models import Content, ContentStatus, QualityMetrics
from src.config import get_config
class LightScanner:
"""轻量内容扫描器 - 快速生成摘要和关键词"""
def __init__(self, ai_client=None):
self.config = get_config()
self.ai_client = ai_client
def scan(self, content: Content, content_text: str) -> Content:
"""
对内容进行轻量扫描
Args:
content: Content对象
content_text: 内容文本(字幕或正文)
Returns:
更新后的Content对象
"""
# 截断文本避免过长
text_sample = self._get_text_sample(content_text)
# 生成一句话摘要
summary = self._generate_summary(text_sample, content.content_type.value)
# 提取关键词
keywords = self._extract_keywords(text_sample)
# 质量评分
quality_score = self._calculate_quality_score(text_sample, content)
# 更新Content对象
content.summary = summary
content.key_points = keywords # 使用key_points存储关键词
content.quality_metrics.overall_score = quality_score / 10 # 转换为0-1范围
content.update_status(ContentStatus.SCANNED)
return content
def _get_text_sample(self, text: str, max_chars: int = 3000) -> str:
"""获取文本样本,优先取开头、中间和结尾"""
if len(text) <= max_chars:
return text
# 分为三部分:开头40%、中间20%、结尾40%
start_len = int(max_chars * 0.4)
end_len = int(max_chars * 0.4)
middle_len = max_chars - start_len - end_len
start = text[:start_len]
middle_start = len(text) // 2 - middle_len // 2
middle = text[middle_start:middle_start + middle_len]
end = text[-end_len:]
return f"{start}\n\n[...中间部分...]\n\n{middle}\n\n[...省略...]\n\n{end}"
def _generate_summary(self, text_sample: str, content_type: str) -> str:
"""
生成一句话摘要
如果有AI客户端则调用AI,否则使用简单启发式方法
"""
if self.ai_client:
return self._generate_summary_with_ai(text_sample, content_type)
else:
return self._generate_summary_heuristic(text_sample)
def _generate_summary_with_ai(self, text_sample: str, content_type: str) -> str:
"""使用AI生成摘要"""
prompt = f"""请为以下{content_type}内容生成一句话摘要(不超过50字):
{text_sample[:2000]}
要求:
1. 一句话概括核心内容
2. 不超过50字
3. 突出主要观点和价值
摘要:"""
try:
response = self.ai_client.quick_generate(prompt, max_tokens=100)
# 清理并截断
summary = response.strip().replace('\n', ' ')
if len(summary) > 60:
summary = summary[:57] + "..."
return summary
except Exception as e:
print(f"[LightScanner] AI生成摘要失败: {e}")
return self._generate_summary_heuristic(text_sample)
def _generate_summary_heuristic(self, text: str) -> str:
"""启发式生成简单摘要"""
# 取前100个字符作为简单摘要
sentences = re.split(r'[。!?.!?]', text)
for sent in sentences:
sent = sent.strip()
if len(sent) > 20 and len(sent) < 100:
return sent[:50] + "..." if len(sent) > 50 else sent
# 回退:取前50字
text = text.replace('\n', ' ').strip()
return text[:47] + "..." if len(text) > 50 else text
def _extract_keywords(self, text: str) -> List[str]:
"""
提取5个关键词
如果有AI客户端则调用AI,否则使用简单词频分析
"""
if self.ai_client:
return self._extract_keywords_with_ai(text)
else:
return self._extract_keywords_heuristic(text)
def _extract_keywords_with_ai(self, text: str) -> List[str]:
"""使用AI提取关键词"""
prompt = f"""请从以下内容中提取5个最重要的关键词,用逗号分隔:
{text[:1500]}
关键词(5个,逗号分隔):"""
try:
response = self.ai_client.quick_generate(prompt, max_tokens=100)
# 解析逗号分隔的关键词
keywords = [k.strip() for k in response.split(',') if k.strip()]
if len(keywords) < 5:
keywords = [k.strip() for k in response.split(',') if k.strip()]
# 确保最多5个,每个不超过10字
keywords = [k[:10] for k in keywords[:5]]
# 如果不足5个,补充通用关键词
while len(keywords) < 5:
keywords.append(f"关键词{len(keywords)+1}")
return keywords[:5]
except Exception as e:
print(f"[LightScanner] AI提取关键词失败: {e}")
return self._extract_keywords_heuristic(text)
def _extract_keywords_heuristic(self, text: str) -> List[str]:
"""启发式提取关键词(基于词频)"""
# 简单的中文/英文分词和词频统计
import re
from collections import Counter
# 提取中文字符串(2-6字)
chinese_words = re.findall(r'[\u4e00-\u9fff]{2,6}', text)
# 提取英文单词
english_words = re.findall(r'\b[a-zA-Z]{4,12}\b', text.lower())
# 停用词过滤
stopwords = {'这个', '那个', '什么', '一个', '我们', '你们', '他们', '可以', '进行'}
chinese_words = [w for w in chinese_words if w not in stopwords]
# 统计词频
all_words = chinese_words + english_words
if not all_words:
return ["内容", "分析", "知识", "笔记", "学习"]
word_freq = Counter(all_words)
top_words = [word for word, _ in word_freq.most_common(5)]
# 确保有5个
defaults = ["内容", "分析", "知识", "笔记", "学习"]
while len(top_words) < 5:
top_words.append(defaults[len(top_words)])
return top_words[:5]
def _calculate_quality_score(self, text: str, content: Content) -> float:
"""
计算内容质量评分(0-10)
基于多个维度:文本长度、信息密度、结构完整性
"""
score = 5.0 # 基础分
# 1. 文本长度评分 (0-3分)
text_len = len(text)
if text_len > 10000:
score += 3.0
elif text_len > 5000:
score += 2.5
elif text_len > 2000:
score += 2.0
elif text_len > 1000:
score += 1.0
elif text_len > 500:
score += 0.5
# 2. 信息密度评分 (0-2分) - 基于句子平均长度
sentences = re.split(r'[。!?.!?]', text)
sentences = [s for s in sentences if s.strip()]
if sentences:
avg_sent_len = text_len / len(sentences)
if 15 <= avg_sent_len <= 50:
score += 2.0
elif 10 <= avg_sent_len <= 80:
score += 1.0
# 3. 结构完整性 (0-2分)
# 检查是否有章节标题模式
if re.search(r'[#\d]+[.、\s]', text):
score += 1.0
# 检查是否有时间戳(视频字幕)
if content.duration and content.duration > 0:
score += 1.0
# 4. 多样性评分 (0-2分) - 基于唯一词比例
words = re.findall(r'[\u4e00-\u9fff]', text)
if words:
unique_ratio = len(set(words)) / len(words)
if unique_ratio > 0.5:
score += 2.0
elif unique_ratio > 0.3:
score += 1.0
# 确保在0-10范围内
return round(max(0.0, min(10.0, score)), 1)
-60
View File
@@ -1,60 +0,0 @@
"""LLM processor for content analysis and summarization."""
from typing import Any, Dict, List
class LLMProcessor:
"""Processes content using LLM for analysis and summarization."""
def __init__(self, provider: str = 'openai'):
"""Initialize LLM processor.
Args:
provider: LLM provider name
"""
self.provider = provider
def analyze(self, content: str) -> Dict[str, Any]:
"""Analyze content quality and relevance.
Args:
content: Text content to analyze
Returns:
Analysis results with quality score, relevance, and topics
"""
# Placeholder - implement actual LLM integration
return {
'quality_score': 0.7,
'relevance': 'high',
'topics': ['technology', 'tutorial'],
'summary': content[:200] + '...' if len(content) > 200 else content
}
def summarize(self, content: str, max_length: int = 500) -> str:
"""Generate summary of content.
Args:
content: Text content to summarize
max_length: Maximum summary length
Returns:
Summarized text
"""
# Placeholder - implement actual LLM summarization
if len(content) <= max_length:
return content
return content[:max_length] + "..."
def generate_tags(self, content: str, title: str = '') -> List[str]:
"""Generate tags for content.
Args:
content: Text content
title: Content title
Returns:
List of suggested tags
"""
# Placeholder - implement actual tag generation
return ['imported', 'unread']
-213
View File
@@ -1,213 +0,0 @@
"""Obsidian KB - 知识库内容管理模型"""
from enum import Enum
from dataclasses import dataclass, field, asdict
from typing import Optional, List, Dict, Any
from datetime import datetime
import uuid
import json
class ContentType(Enum):
"""内容类型"""
VIDEO = "video"
ARTICLE = "article"
class ContentStatus(Enum):
"""内容处理状态"""
NEW = "new" # 新发现
SCANNED = "scanned" # 已扫描元数据
CONFIRMED = "confirmed" # 已确认处理
ANALYZED = "analyzed" # 已AI分析
GENERATED = "generated" # 已生成笔记
SKIPPED = "skipped" # 已跳过
@dataclass
class ContentChunk:
"""内容块 - 用于分段处理"""
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
content: str = "" # 内容文本
start_time: Optional[int] = None # 开始时间(秒)
end_time: Optional[int] = None # 结束时间(秒)
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "ContentChunk":
return cls(**data)
@dataclass
class ContentAnalysis:
"""内容分析结果 - 用于模板渲染"""
one_liner: str = "" # 一句话摘要
summary: str = "" # 详细摘要
key_points: List[str] = field(default_factory=list) # 关键要点
tags: List[str] = field(default_factory=list) # 标签
quality_score: float = 0.0 # 质量评分 0-100
# 教程视频专用字段
prerequisites: List[str] = field(default_factory=list) # 前置知识
outline: List[Dict[str, Any]] = field(default_factory=list) # 大纲分段
pitfalls: List[str] = field(default_factory=list) # 常见坑点
practice_advice: List[str] = field(default_factory=list) # 实践建议
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "ContentAnalysis":
return cls(**data)
def is_high_quality(self, threshold: float = 60.0) -> bool:
"""判断是否高质量内容"""
return self.quality_score >= threshold
@dataclass
class QualityMetrics:
"""内容质量指标"""
overall_score: float = 0.0 # 综合评分 0-1
information_density: float = 0.0 # 信息密度
originality: float = 0.0 # 原创性
practicality: float = 0.0 # 实用性
depth: float = 0.0 # 深度
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "QualityMetrics":
return cls(**data)
@dataclass
class AnalysisResult:
"""AI深度分析结果"""
main_topics: List[str] = field(default_factory=list) # 主要主题
key_insights: List[str] = field(default_factory=list) # 关键洞察
action_items: List[str] = field(default_factory=list) # 行动项
summary: str = "" # 摘要
timeline: List[Dict[str, Any]] = field(default_factory=list) # 时间线
recommended_tags: List[str] = field(default_factory=list) # 推荐标签
full_analysis: str = "" # 完整分析
timeline_summary: List[Dict[str, Any]] = field(default_factory=list) # 时间线摘要(兼容字段)
content_structure: str = "" # 内容结构描述
target_audience: str = "" # 目标受众
difficulty_level: str = "intermediate" # 难度级别
def to_dict(self) -> Dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "AnalysisResult":
return cls(**data)
@dataclass
class TimelineEntry:
"""时间线条目"""
timestamp: str
title: str
description: str
def to_dict(self) -> Dict[str, str]:
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, str]) -> "TimelineEntry":
return cls(**data)
@dataclass
class Content:
"""内容数据模型"""
# 基本信息
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
source: str = "" # 来源: bilibili, youtube, zhihu, local
source_id: str = "" # 来源唯一ID
url: str = ""
title: str = ""
author: str = ""
description: str = ""
duration: Optional[int] = None # 视频时长(秒)
# 内容类型与状态
content_type: ContentType = ContentType.VIDEO
status: ContentStatus = ContentStatus.NEW
# 元数据
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
processed_at: Optional[str] = None
# 原始内容
transcript: str = "" # 字幕/原文
# AI分析结果
summary: str = "" # 摘要
one_liner: str = "" # 一句话摘要
key_points: List[str] = field(default_factory=list) # 关键要点
tags: List[str] = field(default_factory=list) # 标签
quality_score: float = 0.0 # 质量评分
analysis: Optional[ContentAnalysis] = None # 详细分析结果
outline: List[str] = field(default_factory=list) # 大纲
timeline: List[TimelineEntry] = field(default_factory=list) # 时间线
quality_metrics: QualityMetrics = field(default_factory=QualityMetrics)
# 分类与标签
category: str = "" # 主题分类
# 输出
output_path: Optional[str] = None # 生成的笔记路径
note_path: Optional[str] = None # 别名,用于兼容
def to_dict(self) -> Dict[str, Any]:
"""序列化为字典"""
data = asdict(self)
data['content_type'] = self.content_type.value
data['status'] = self.status.value
data['quality_metrics'] = self.quality_metrics.to_dict()
data['timeline'] = [e.to_dict() for e in self.timeline]
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "Content":
"""从字典反序列化"""
# 处理枚举
data['content_type'] = ContentType(data.get('content_type', 'video'))
data['status'] = ContentStatus(data.get('status', 'new'))
# 处理嵌套对象
if 'quality_metrics' in data and isinstance(data['quality_metrics'], dict):
data['quality_metrics'] = QualityMetrics.from_dict(data['quality_metrics'])
else:
data['quality_metrics'] = QualityMetrics()
if 'timeline' in data and isinstance(data['timeline'], list):
data['timeline'] = [TimelineEntry.from_dict(e) for e in data['timeline']]
else:
data['timeline'] = []
return cls(**{k: v for k, v in data.items() if k in [f.name for f in cls.__dataclass_fields__.values()]})
def to_json(self) -> str:
"""序列化为JSON字符串"""
return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
@classmethod
def from_json(cls, json_str: str) -> "Content":
"""从JSON字符串反序列化"""
return cls.from_dict(json.loads(json_str))
def is_high_quality(self, threshold: float = 0.7) -> bool:
"""判断是否高质量内容"""
return self.quality_metrics.overall_score >= threshold
def update_status(self, status: ContentStatus):
"""更新状态"""
self.status = status
if status == ContentStatus.GENERATED:
self.processed_at = datetime.now().isoformat()
@@ -1,5 +0,0 @@
# Outputs module for obsidian-kb
from .obsidian_writer import ObsidianWriter
from .git_sync import GitSync
__all__ = ['ObsidianWriter', 'GitSync']
-275
View File
@@ -1,275 +0,0 @@
"""
Git Sync - 将 Obsidian vault 同步到 Git 仓库
"""
import os
import subprocess
from pathlib import Path
from typing import Optional, Dict, Any
class GitSync:
"""Git 同步器"""
def __init__(self, config):
"""
初始化同步器
Args:
config: 配置对象
"""
self.config = config
self.vault_path = config.vault_path
def sync(self, config) -> bool:
"""
同步到 Git 仓库
步骤:
1. 检查 git.enabled
2. git add
3. git commit(使用 commit_message_template)
4. 如果 auto_push=true,执行 git push
Args:
config: 配置对象
Returns:
是否成功
"""
# 检查是否启用 Git
if not self._is_enabled():
print("Git 同步未启用,跳过")
return True
# 检查是否在 Git 仓库中
if not self._is_git_repo():
print(f"目录 {self.vault_path} 不是 Git 仓库,跳过同步")
return False
try:
# 添加更改
if not self._git_add():
print("git add 失败")
return False
# 提交更改
if not self._git_commit():
print("git commit 失败")
return False
# 推送更改(如果配置了 auto_push)
if self._should_auto_push():
if not self._git_push():
print("git push 失败")
return False
else:
print("Git 同步完成(包含推送)")
else:
print("Git 同步完成(仅提交)")
return True
except Exception as e:
print(f"Git 同步失败: {e}")
return False
def _is_enabled(self) -> bool:
"""
检查 Git 同步是否启用
Returns:
是否启用
"""
return self.config.enabled
def _is_git_repo(self) -> bool:
"""
检查当前目录是否是 Git 仓库
Returns:
是否是 Git 仓库
"""
git_dir = self.vault_path / '.git'
return git_dir.exists()
def _git_add(self) -> bool:
"""
执行 git add
Returns:
是否成功
"""
try:
result = subprocess.run(
['git', 'add', '-A'],
cwd=self.vault_path,
capture_output=True,
text=True,
check=True
)
return True
except subprocess.CalledProcessError as e:
print(f"git add 失败: {e.stderr}")
return False
except FileNotFoundError:
print("未找到 git 命令,请确保已安装 Git")
return False
def _git_commit(self) -> bool:
"""
执行 git commit
Returns:
是否成功
"""
# 检查是否有更改需要提交
if not self._has_changes():
print("没有更改需要提交")
return True
# 获取提交消息模板
commit_message = self._get_commit_message()
try:
result = subprocess.run(
['git', 'commit', '-m', commit_message],
cwd=self.vault_path,
capture_output=True,
text=True,
check=True
)
print(f"提交成功: {commit_message}")
return True
except subprocess.CalledProcessError as e:
print(f"git commit 失败: {e.stderr}")
return False
def _has_changes(self) -> bool:
"""
检查是否有更改需要提交
Returns:
是否有更改
"""
try:
# 检查工作区和暂存区是否有更改
result = subprocess.run(
['git', 'status', '--porcelain'],
cwd=self.vault_path,
capture_output=True,
text=True,
check=True
)
# 如果有输出,说明有更改
return bool(result.stdout.strip())
except subprocess.CalledProcessError:
return False
def _get_commit_message(self) -> str:
"""
获取提交消息
从 commit_message_template 模板生成
Returns:
提交消息
"""
template = self.config.commit_message_template
# 简单的模板替换
# 模板格式: "Add note: {title}"
# 目前只支持 {title} 占位符
# 尝试获取最新的文件信息作为标题
# 这里我们使用一个简单的实现
# 实际中可能需要从传入的数据中获取
import datetime
timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# 简单的模板替换
# 如果模板包含 {title},替换为时间戳
# 在实际使用中,应该传入具体的标题
commit_message = template.replace('{title}', timestamp)
commit_message = commit_message.replace('{date}', timestamp)
return commit_message
def _should_auto_push(self) -> bool:
"""
检查是否应该自动推送
Returns:
是否应该自动推送
"""
return self.config.auto_push
def _git_push(self) -> bool:
"""
执行 git push
Returns:
是否成功
"""
try:
# 获取当前分支
branch = self._get_current_branch()
# 推送到远程
result = subprocess.run(
['git', 'push', 'origin', branch],
cwd=self.vault_path,
capture_output=True,
text=True,
check=True
)
print(f"推送成功: origin/{branch}")
return True
except subprocess.CalledProcessError as e:
print(f"git push 失败: {e.stderr}")
return False
def _get_current_branch(self) -> str:
"""
获取当前 Git 分支
Returns:
分支名称
"""
try:
result = subprocess.run(
['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
cwd=self.vault_path,
capture_output=True,
text=True,
check=True
)
return result.stdout.strip()
except subprocess.CalledProcessError:
return 'main'
def sync_with_message(self, title: Optional[str] = None) -> bool:
"""
使用自定义标题同步到 Git 仓库
Args:
title: 内容标题,用于生成提交消息
Returns:
是否成功
"""
# 保存原始模板
original_template = self.config._config.get('git', {}).get('commit_message_template')
# 临时修改模板
if title:
self.config._config['git']['commit_message_template'] = original_template.replace('{title}', title)
# 执行同步
result = self.sync(self.config)
# 恢复原始模板
if original_template:
self.config._config['git']['commit_message_template'] = original_template
return result
@@ -1,385 +0,0 @@
"""
Obsidian Writer - 将内容写入 Obsidian vault
"""
import os
import re
from pathlib import Path
from datetime import datetime
from typing import Dict, Any, Optional
from ..models import Content
class ObsidianWriter:
"""Obsidian vault 写入器"""
def __init__(self, config):
"""
初始化写入器
Args:
config: 配置对象
"""
self.config = config
self.vault_path = config.vault_path
def write(self, data: Dict[str, Any], config) -> Optional[str]:
"""
将内容写入 Obsidian vault
Args:
data: 内容数据字典或 Content 对象
config: 配置对象
Returns:
写入的文件路径,失败返回 None
"""
# 如果 data 是 Content 对象,转换为字典
if isinstance(data, Content):
content = data
else:
content = Content.from_dict(data)
# 构建文件路径
file_path = self._build_file_path(content)
# 创建目录结构
file_path.parent.mkdir(parents=True, exist_ok=True)
# 生成 Markdown 内容
markdown_content = self._generate_markdown(content)
# 写入文件
try:
with open(file_path, 'w', encoding='utf-8') as f:
f.write(markdown_content)
return str(file_path)
except Exception as e:
print(f"写入文件失败: {e}")
return None
def _build_file_path(self, content: Content) -> Path:
"""
构建文件路径
格式: {vault}/videos/bilibili/{author}/{date}_{title}.md
Args:
content: Content 对象
Returns:
文件路径
"""
# 获取日期(从 created_at 提取日期部分)
date_str = self._extract_date(content.created_at)
# 清理标题(移除特殊字符)
safe_title = self._sanitize_filename(content.title)
# 构建文件名
filename = f"{date_str}_{safe_title}.md"
# 构建完整路径
# 格式: {vault}/videos/{source}/{author}/{filename}
path_parts = [
self.vault_path,
'videos',
content.source,
self._sanitize_filename(content.author),
filename
]
return Path(*path_parts)
def _extract_date(self, iso_date: str) -> str:
"""
从 ISO 日期字符串提取日期部分
Args:
iso_date: ISO 格式日期字符串
Returns:
YYYY-MM-DD 格式日期字符串
"""
try:
dt = datetime.fromisoformat(iso_date)
return dt.strftime('%Y-%m-%d')
except:
# 如果解析失败,返回当前日期
return datetime.now().strftime('%Y-%m-%d')
def _sanitize_filename(self, filename: str) -> str:
"""
清理文件名,移除不适合文件系统的字符
Args:
filename: 原始文件名
Returns:
清理后的文件名
"""
# 移除不适合文件系统的字符
forbidden_chars = '<>:"/\\|?*'
for char in forbidden_chars:
filename = filename.replace(char, '')
# 移除首尾空格
filename = filename.strip()
# 限制长度
if len(filename) > 100:
filename = filename[:100]
# 如果为空,使用默认值
if not filename:
filename = 'untitled'
return filename
def _generate_markdown(self, content: Content) -> str:
"""
生成 Obsidian Markdown 内容
格式:
---
title: xxx
source: bilibili
url: xxx
author: xxx
duration: xxx
date: xxx
tags: [xxx, xxx]
---
# {title}
...内容...
Args:
content: Content 对象
Returns:
Markdown 内容字符串
"""
# 生成 frontmatter
frontmatter = self._generate_frontmatter(content)
# 生成正文
body = self._generate_body(content)
# 组合
markdown = f"{frontmatter}\n{body}"
return markdown
def _generate_frontmatter(self, content: Content) -> str:
"""
生成 YAML frontmatter
Args:
content: Content 对象
Returns:
Frontmatter 字符串
"""
# 格式化时长
duration_str = self._format_duration(content.duration) if content.duration else "N/A"
# 格式化日期
date_str = self._extract_date(content.created_at)
# 构建标签列表
tags = self._build_tags(content)
tags_yaml = ", ".join([f'"{tag}"' for tag in tags])
frontmatter = f"""---
title: {content.title}
source: {content.source}
url: {content.url}
author: {content.author}
duration: {duration_str}
date: {date_str}
tags: [{tags_yaml}]
---"""
return frontmatter
def _build_tags(self, content: Content) -> list:
"""
构建标签列表
Args:
content: Content 对象
Returns:
标签列表
"""
tags = []
# 添加自动标签
if self.config.auto_tags:
tags.extend(self.config.default_tags)
# 添加来源标签
if content.source:
tags.append(content.source)
# 添加内容类型标签
if content.content_type:
tags.append(content.content_type.value)
# 添加 AI 生成的标签
if content.tags:
tags.extend(content.tags)
# 去重
tags = list(dict.fromkeys(tags))
return tags
def _format_duration(self, duration_seconds: int) -> str:
"""
格式化时长为 HH:MM:SS
Args:
duration_seconds: 时长(秒)
Returns:
格式化的时长字符串
"""
if duration_seconds is None:
return "N/A"
hours = duration_seconds // 3600
minutes = (duration_seconds % 3600) // 60
seconds = duration_seconds % 60
if hours > 0:
return f"{hours}:{minutes:02d}:{seconds:02d}"
else:
return f"{minutes}:{seconds:02d}"
def _generate_body(self, content: Content) -> str:
"""
生成 Markdown 正文内容
Args:
content: Content 对象
Returns:
正文内容字符串
"""
body_parts = []
# 标题
body_parts.append(f"# {content.title}")
body_parts.append("")
# 一句话摘要
if content.one_liner:
body_parts.append(f"> {content.one_liner}")
body_parts.append("")
# 摘要
if content.summary:
body_parts.append("## 摘要")
body_parts.append("")
body_parts.append(content.summary)
body_parts.append("")
# 关键要点
if content.key_points:
body_parts.append("## 关键要点")
body_parts.append("")
for point in content.key_points:
body_parts.append(f"- {point}")
body_parts.append("")
# 大纲
if content.outline:
body_parts.append("## 大纲")
body_parts.append("")
for i, item in enumerate(content.outline, 1):
body_parts.append(f"{i}. {item}")
body_parts.append("")
# 时间线
if content.timeline:
body_parts.append("## 时间线")
body_parts.append("")
for entry in content.timeline:
timestamp = entry.timestamp or "N/A"
title = entry.title or ""
description = entry.description or ""
body_parts.append(f"### {timestamp} - {title}")
if description:
body_parts.append(description)
body_parts.append("")
# 内容分析
if content.analysis and content.analysis.outline:
body_parts.append("## 详细分析")
body_parts.append("")
analysis = content.analysis
# 前置知识
if analysis.prerequisites:
body_parts.append("### 前置知识")
body_parts.append("")
for prereq in analysis.prerequisites:
body_parts.append(f"- {prereq}")
body_parts.append("")
# 详细大纲
if analysis.outline:
body_parts.append("### 详细大纲")
body_parts.append("")
for section in analysis.outline:
if isinstance(section, dict):
title = section.get('title', '')
content_text = section.get('content', '')
body_parts.append(f"#### {title}")
if content_text:
body_parts.append(content_text)
else:
body_parts.append(str(section))
body_parts.append("")
# 常见坑点
if analysis.pitfalls:
body_parts.append("### 常见坑点")
body_parts.append("")
for pitfall in analysis.pitfalls:
body_parts.append(f"- {pitfall}")
body_parts.append("")
# 实践建议
if analysis.practice_advice:
body_parts.append("### 实践建议")
body_parts.append("")
for advice in analysis.practice_advice:
body_parts.append(f"- {advice}")
body_parts.append("")
# 原始内容(如果有)
if content.transcript:
body_parts.append("## 原始内容")
body_parts.append("")
body_parts.append(content.transcript)
body_parts.append("")
# 无字幕提示
if not content.transcript:
body_parts.append("*此视频无字幕,仅保存元数据*")
body_parts.append("")
# 元数据
body_parts.append("---")
body_parts.append(f"- **来源**: [{content.author} - {content.title}]({content.url})")
if content.duration:
duration_str = self._format_duration(content.duration)
body_parts.append(f"- **时长**: {duration_str}")
body_parts.append(f"- **处理日期**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
return "\n".join(body_parts)
-273
View File
@@ -1,273 +0,0 @@
"""Obsidian KB - 内容处理管道
C4.3: 实现完整处理流程
"""
import os
from datetime import datetime
from typing import Optional
from src.models import Content, ContentStatus, AnalysisResult
from src.light_scanner import LightScanner
from src.deep_analyzer import DeepAnalyzer
from src.config import get_config
class ProcessingPipeline:
"""内容处理管道 - 协调轻量扫描、深度分析、笔记生成和同步"""
def __init__(self, ai_client=None, git_client=None):
self.config = get_config()
self.ai_client = ai_client
self.git_client = git_client
# 初始化处理器
self.light_scanner = LightScanner(ai_client=ai_client)
self.deep_analyzer = DeepAnalyzer(ai_client=ai_client)
def light_scan(self, content: Content, content_text: str) -> Content:
"""
轻量扫描阶段
Args:
content: Content对象
content_text: 内容文本
Returns:
更新后的Content对象
"""
print(f"[Pipeline] 开始轻量扫描: {content.id}")
try:
content = self.light_scanner.scan(content, content_text)
print(f"[Pipeline] 轻量扫描完成: 摘要长度={len(content.summary)}, 关键词={content.key_points}, 质量分={content.quality_metrics.overall_score:.2f}")
return content
except Exception as e:
print(f"[Pipeline] 轻量扫描失败: {e}")
raise
def deep_analyze(self, content: Content, content_text: str) -> Content:
"""
深度分析阶段
Args:
content: Content对象
content_text: 内容文本
Returns:
更新后的Content对象
"""
print(f"[Pipeline] 开始深度分析: {content.id}")
try:
analysis_result = self.deep_analyzer.analyze(content, content_text)
print(f"[Pipeline] 深度分析完成: 主题数={len(analysis_result.main_topics)}, 时间轴段数={len(analysis_result.timeline)}")
return content
except Exception as e:
print(f"[Pipeline] 深度分析失败: {e}")
content.update_status(ContentStatus.ERROR)
raise
def process_full(self, content: Content, content_text: str) -> Content:
"""
完整处理流程:
轻量扫描 → 入库 → 深度分析 → 生成笔记 → Git同步
Args:
content: Content对象
content_text: 内容文本
Returns:
完全处理后的Content对象
"""
print(f"\n{'='*60}")
print(f"[Pipeline] 开始完整处理流程: {content.id}")
print(f"{'='*60}\n")
try:
# Step 1: 轻量扫描
print("\n📋 Step 1: 轻量扫描")
content = self.light_scan(content, content_text)
# Step 2: 入库(保存到数据库/文件系统)
print("\n💾 Step 2: 保存内容")
self._save_content(content)
# Step 3: 深度分析
print("\n🔍 Step 3: 深度分析")
content = self.deep_analyze(content, content_text)
# Step 4: 生成笔记
print("\n📝 Step 4: 生成Obsidian笔记")
content = self._generate_note(content)
# Step 5: Git同步
if self.config.git.enabled:
print("\n🔄 Step 5: Git同步")
self._sync_to_git(content)
else:
print("\n⏭️ Step 5: Git同步已禁用,跳过")
content.update_status(ContentStatus.GENERATED)
print(f"\n{'='*60}")
print(f"[Pipeline] 处理完成: {content.id}")
print(f"{'='*60}\n")
return content
except Exception as e:
print(f"\n❌ [Pipeline] 处理流程失败: {e}")
raise
def _save_content(self, content: Content) -> None:
"""保存内容到存储"""
# 创建存储目录
storage_dir = os.path.expanduser("~/.obsidian-kb/storage")
os.makedirs(storage_dir, exist_ok=True)
# 保存JSON文件
import json
file_path = os.path.join(storage_dir, f"{content.id}.json")
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(content.to_dict(), f, ensure_ascii=False, indent=2)
print(f"[Pipeline] 内容已保存: {file_path}")
def _generate_note(self, content: Content) -> Content:
"""生成Obsidian笔记"""
# 构建笔记内容
note_content = self._build_note_content(content)
# 确定笔记路径
vault_path = os.path.expanduser(self.config.obsidian.vault_path)
notes_folder = self.config.obsidian.notes_folder
note_dir = os.path.join(vault_path, notes_folder)
os.makedirs(note_dir, exist_ok=True)
# 生成文件名
safe_title = self._sanitize_filename(content.title or content.id)
timestamp = datetime.now().strftime("%Y%m%d")
note_filename = f"{timestamp}_{safe_title}.md"
note_path = os.path.join(note_dir, note_filename)
# 写入文件
with open(note_path, 'w', encoding='utf-8') as f:
f.write(note_content)
content.output_path = note_path
content.update_status(ContentStatus.GENERATED)
print(f"[Pipeline] 笔记已生成: {note_path}")
return content
def _build_note_content(self, content: Content) -> str:
"""构建Obsidian笔记内容"""
lines = [
f"# {content.title or '未命名内容'}",
"",
"## 📊 元数据",
f"- **来源**: {content.source}",
f"- **URL**: {content.url}",
f"- **作者**: {content.author or '未知'}",
f"- **类型**: {content.content_type.value}",
]
if content.duration:
duration_str = self._format_duration(content.duration)
lines.append(f"- **时长**: {duration_str}")
lines.extend([
f"- **质量评分**: {content.quality_metrics.overall_score:.1f}/1.0",
f"- **处理时间**: {datetime.now().strftime('%Y-%m-%d %H:%M')}",
"",
"## 📝 摘要",
content.summary or "暂无摘要",
"",
"## 🏷️ 关键词",
])
# 添加关键词标签
if content.key_points:
keyword_tags = ' '.join([f"#{kw.replace(' ', '_')}" for kw in content.key_points[:5]])
lines.append(keyword_tags)
lines.append('')
# 添加时间轴
if content.timeline:
lines.extend([
"",
"## ⏱️ 时间轴",
"",
])
for item in content.timeline:
lines.append(f"### {item.timestamp} - {item.title}")
lines.append(item.description)
lines.append('')
# 添加原始内容(折叠)
lines.extend([
"",
"## 📄 原始内容",
"",
"```",
content.transcript[:2000] + "..." if len(content.transcript) > 2000 else content.transcript,
"```",
"",
"---",
f"*由 Obsidian KB 自动生成 - {datetime.now().strftime('%Y-%m-%d %H:%M')}*"
])
return '\n'.join(lines)
def _sync_to_git(self, content: Content) -> None:
"""同步到Git仓库"""
if not self.git_client:
print("[Pipeline] Git客户端未配置,跳过同步")
return
try:
vault_path = os.path.expanduser(self.config.obsidian.vault_path)
# 添加文件
relative_path = os.path.relpath(content.output_path, vault_path)
self.git_client.add(relative_path)
# 提交
commit_msg = self.config.git.commit_message_template.format(
title=content.title or content.id
)
self.git_client.commit(commit_msg)
# 推送(如果启用)
if self.config.git.auto_push:
self.git_client.push()
print(f"[Pipeline] Git同步完成")
except Exception as e:
print(f"[Pipeline] Git同步失败: {e}")
# Git同步失败不阻断主流程
def _sanitize_filename(self, filename: str) -> str:
"""清理文件名"""
import re
# 替换非法字符
filename = re.sub(r'[<>:"/\\|?*]', '_', filename)
# 限制长度
if len(filename) > 50:
filename = filename[:50]
return filename.strip()
def _format_duration(self, seconds: int) -> str:
"""格式化时长"""
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}秒"
@@ -1,31 +0,0 @@
"""
Sources 模块初始化
"""
from .base import SourceAdapter
from .bilibili import BilibiliAdapter
class SourceRegistry:
"""源适配器注册表"""
_adapters = {}
@classmethod
def register(cls, name: str, adapter_class: type):
"""注册适配器类"""
cls._adapters[name] = adapter_class
@classmethod
def get(cls, name: str) -> SourceAdapter:
"""获取适配器类"""
return cls._adapters.get(name)
@classmethod
def list(cls) -> list:
"""列出所有已注册的适配器名称"""
return list(cls._adapters.keys())
# 自动注册适配器
SourceRegistry.register('bilibili', BilibiliAdapter)
__all__ = ['SourceAdapter', 'BilibiliAdapter', 'SourceRegistry']
-58
View File
@@ -1,58 +0,0 @@
"""
Source Adapter 抽象基类
"""
from abc import ABC, abstractmethod
from datetime import datetime
from typing import List, Optional
from models import Content
class SourceAdapter(ABC):
"""内容源适配器基类"""
@abstractmethod
def fetch_new(self, since: datetime, **kwargs) -> List[Content]:
"""
获取新内容
Args:
since: 起始时间,只获取该时间之后的内容
**kwargs: 额外的参数
Returns:
List[Content]: 新内容列表
"""
pass
@abstractmethod
def extract_content(self, url: str, **kwargs) -> Optional[Content]:
"""
从URL提取内容
Args:
url: 内容URL
**kwargs: 额外的参数
Returns:
Optional[Content]: 内容对象,如果提取失败返回None
"""
pass
@abstractmethod
def get_transcript(self, content: Content, **kwargs) -> Optional[str]:
"""
获取字幕/文本
Args:
content: 内容对象
**kwargs: 额外的参数
Returns:
Optional[str]: 字幕文本(SRT格式),如果没有字幕返回None
"""
pass
# 为向后兼容,提供 BaseSource 别名
BaseSource = SourceAdapter
-466
View File
@@ -1,466 +0,0 @@
"""
Bilibili 视频源适配器
"""
import re
import json
import time
import hashlib
import urllib.parse
from datetime import datetime
from typing import List, Optional, Dict, Any
import urllib.request
from urllib.request import Request, urlopen
from .base import SourceAdapter
from models import Content, ContentType, ContentStatus
class BilibiliAdapter(SourceAdapter):
"""B站视频源适配器"""
BASE_URL = "https://api.bilibili.com"
# WBI 混淆密钥表
MIXIN_KEY_ENC_TABLE = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13,
37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4,
22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52
]
def __init__(self, sessdata: Optional[str] = None):
"""
初始化适配器
Args:
sessdata: B站登录凭证(可选,用于获取私人收藏夹)
"""
self.sessdata = sessdata
self._img_key = None
self._sub_key = None
def _get_headers(self) -> Dict[str, str]:
"""获取请求头"""
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'https://www.bilibili.com',
'Accept': 'application/json, text/plain, */*',
}
if self.sessdata:
headers['Cookie'] = f'SESSDATA={self.sessdata}'
return headers
def _get_wbi_keys(self) -> tuple:
"""获取WBI密钥"""
if self._img_key and self._sub_key:
return self._img_key, self._sub_key
url = f"{self.BASE_URL}/x/web-interface/nav"
try:
req = Request(url, headers=self._get_headers())
with urlopen(req, timeout=30) as response:
data = json.loads(response.read().decode('utf-8'))
if data.get('code') == 0:
img_url = data['data']['wbi_img']['img_url']
sub_url = data['data']['wbi_img']['sub_url']
# 提取密钥
img_key = img_url.split('/')[-1].split('.')[0]
sub_key = sub_url.split('/')[-1].split('.')[0]
self._img_key = img_key
self._sub_key = sub_key
return img_key, sub_key
except Exception as e:
print(f"获取WBI密钥失败: {e}")
return None, None
def _get_mixin_key(self, img_key: str, sub_key: str) -> str:
"""生成Mixin密钥"""
raw_key = img_key + sub_key
mixin_key = ''.join([raw_key[i] for i in self.MIXIN_KEY_ENC_TABLE])[:32]
return mixin_key
def _encode_wbi(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""WBI签名参数"""
img_key, sub_key = self._get_wbi_keys()
if not img_key or not sub_key:
return params
# 添加时间戳
params['wts'] = int(time.time())
# 按字典序排序参数
sorted_params = dict(sorted(params.items()))
# 构建查询字符串
query = urllib.parse.urlencode(sorted_params)
# 生成Mixin密钥并计算签名
mixin_key = self._get_mixin_key(img_key, sub_key)
sign = hashlib.md5((query + mixin_key).encode()).hexdigest()
sorted_params['w_rid'] = sign
return sorted_params
def _fetch_json(self, url: str, params: Optional[Dict] = None, use_wbi: bool = False) -> Optional[Dict]:
"""
发送HTTP GET请求并返回JSON数据
Args:
url: 请求URL
params: 查询参数
use_wbi: 是否使用WBI签名
Returns:
Optional[Dict]: JSON数据或None
"""
try:
if params:
if use_wbi:
params = self._encode_wbi(params)
query = urllib.parse.urlencode(params)
url = f"{url}?{query}"
req = Request(url, headers=self._get_headers())
with urlopen(req, timeout=30) as response:
return json.loads(response.read().decode('utf-8'))
except Exception as e:
print(f"请求失败 {url}: {e}")
return None
def _extract_bvid_from_url(self, url: str) -> Optional[str]:
"""
从URL提取BV号
Args:
url: B站视频URL
Returns:
Optional[str]: BV号或None
"""
patterns = [
r'/video/(BV[\w]+)',
r'bvid=([\w]+)',
r'(BV[\w]+)',
]
for pattern in patterns:
match = re.search(pattern, url, re.IGNORECASE)
if match:
bvid = match.group(1)
if bvid.upper().startswith('BV'):
return bvid
return None
def _timestamp_to_datetime(self, timestamp: int) -> Optional[datetime]:
"""时间戳转datetime"""
try:
if timestamp > 10000000000: # 毫秒时间戳
timestamp = timestamp // 1000
return datetime.fromtimestamp(timestamp)
except:
return None
def fetch_new(self, since: datetime, **kwargs) -> List[Content]:
"""
获取新内容
Args:
since: 起始时间
**kwargs: 包含 media_id(收藏夹ID) 或 mid(UP主ID)
Returns:
List[Content]: 新内容列表
"""
contents = []
# 获取收藏夹视频
media_id = kwargs.get('media_id')
if media_id:
fav_contents = self._fetch_favorites(media_id, since)
contents.extend(fav_contents)
# 获取UP主视频
mid = kwargs.get('mid')
if mid:
up_contents = self._fetch_up_videos(mid, since)
contents.extend(up_contents)
return contents
def _fetch_favorites(self, media_id: int, since: datetime) -> List[Content]:
"""
获取收藏夹视频列表
Args:
media_id: 收藏夹ID
since: 起始时间
Returns:
List[Content]: 视频列表
"""
contents = []
page = 1
while True:
url = f"{self.BASE_URL}/x/v3/fav/resource/list"
params = {
'media_id': media_id,
'pn': page,
'ps': 20,
'platform': 'web',
}
data = self._fetch_json(url, params)
if not data or data.get('code') != 0:
break
medias = data.get('data', {}).get('medias', []) or []
if not medias:
break
for media in medias:
content = Content(
source="bilibili",
source_id=media.get('bvid', ''),
url=f"https://www.bilibili.com/video/{media.get('bvid', '')}",
title=media.get('title', ''),
content_type=ContentType.VIDEO,
author=media.get('upper', {}).get('name', ''),
duration=media.get('duration', 0),
)
contents.append(content)
# 检查是否还有更多
info = data.get('data', {}).get('info', {})
total = info.get('media_count', 0)
if page * 20 >= total:
break
page += 1
return contents
def _fetch_up_videos(self, mid: int, since: datetime) -> List[Content]:
"""
获取UP主视频列表
Args:
mid: UP主ID
since: 起始时间
Returns:
List[Content]: 视频列表
"""
contents = []
page = 1
while True:
url = f"{self.BASE_URL}/x/space/wbi/arc/search"
params = {
'mid': mid,
'pn': page,
'ps': 30,
}
data = self._fetch_json(url, params, use_wbi=True)
if not data or data.get('code') != 0:
break
vlist = data.get('data', {}).get('list', {}).get('vlist', [])
if not vlist:
break
for video in vlist:
# 检查时间
created = video.get('created', 0)
pub_datetime = self._timestamp_to_datetime(created)
if pub_datetime and pub_datetime < since:
# 已经处理到旧视频,停止
return contents
content = Content(
source="bilibili",
source_id=video.get('bvid', ''),
url=f"https://www.bilibili.com/video/{video.get('bvid', '')}",
title=video.get('title', ''),
content_type=ContentType.VIDEO,
author=video.get('author', ''),
duration=self._parse_duration(video.get('length', '0:00')),
)
contents.append(content)
page += 1
# 限制最多获取10页
if page > 10:
break
return contents
def _parse_duration(self, duration_str: str) -> int:
"""解析时长字符串为秒数"""
try:
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 0
def extract_content(self, url: str, **kwargs) -> Optional[Content]:
"""
从URL提取内容
Args:
url: B站视频URL
**kwargs: 额外参数
Returns:
Optional[Content]: 内容对象
"""
bvid = self._extract_bvid_from_url(url)
if not bvid:
print(f"无法从URL提取BV号: {url}")
return None
api_url = f"{self.BASE_URL}/x/web-interface/view"
params = {'bvid': bvid}
data = self._fetch_json(api_url, params)
if not data or data.get('code') != 0:
print(f"获取视频详情失败: {data}")
return None
video_data = data.get('data', {})
owner = video_data.get('owner', {})
content = Content(
source="bilibili",
source_id=bvid,
url=url,
title=video_data.get('title', ''),
content_type=ContentType.VIDEO,
author=owner.get('name', ''),
description=video_data.get('desc', ''),
duration=video_data.get('duration', 0),
status=ContentStatus.SCANNED,
)
return content
def get_transcript(self, content: Content, **kwargs) -> Optional[str]:
"""
获取字幕
Args:
content: 内容对象
**kwargs: 额外参数
Returns:
Optional[str]: 字幕文本(SRT格式)
"""
bv_id = content.source_id
if not bv_id:
bv_id = content.id
# 先获取cid
api_url = f"{self.BASE_URL}/x/web-interface/view"
data = self._fetch_json(api_url, {'bvid': bv_id})
if not data or data.get('code') != 0:
return None
cid = data['data'].get('cid')
if not cid:
return None
# 获取字幕列表
subtitle_url = f"{self.BASE_URL}/x/player/wbi/v2"
params = {
'cid': cid,
'bvid': bv_id,
}
data = self._fetch_json(subtitle_url, params, use_wbi=True)
if not data or data.get('code') != 0:
return None
subtitle_info = data.get('data', {}).get('subtitle', {})
subtitles = subtitle_info.get('subtitles', [])
if not subtitles:
return None
# 优先选择AI字幕,其次选择中文简体,最后选择第一个
selected_sub = None
for sub in subtitles:
lan_doc = sub.get('lan_doc', '')
lan = sub.get('lan', '')
if '自动生成' in lan_doc or 'AI' in lan_doc:
selected_sub = sub
break
elif lan == 'zh-CN' or lan == 'zh':
selected_sub = sub
break
if not selected_sub:
selected_sub = subtitles[0]
# 下载字幕内容
sub_url = selected_sub.get('subtitle_url', '')
if not sub_url:
return None
# 确保URL完整
if sub_url.startswith('//'):
sub_url = 'https:' + sub_url
elif sub_url.startswith('/'):
sub_url = 'https://www.bilibili.com' + sub_url
try:
req = Request(sub_url, headers=self._get_headers())
with urlopen(req, timeout=30) as response:
subtitle_data = json.loads(response.read().decode('utf-8'))
except Exception as e:
print(f"下载字幕失败: {e}")
return None
# 解析字幕
body = subtitle_data.get('body', [])
if not body:
return None
# 转换为SRT格式
srt_lines = []
for i, item in enumerate(body, 1):
from_time = item.get('from', 0)
to_time = item.get('to', 0)
text = item.get('content', '').strip()
if not text:
continue
# SRT格式时间码
from_str = self._format_srt_time(from_time)
to_str = self._format_srt_time(to_time)
srt_lines.append(f"{i}")
srt_lines.append(f"{from_str} --> {to_str}")
srt_lines.append(text)
srt_lines.append("")
return '\n'.join(srt_lines)
def _format_srt_time(self, seconds: float) -> str:
"""格式化为SRT时间码"""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
millis = int((seconds % 1) * 1000)
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
-57
View File
@@ -1,57 +0,0 @@
"""Obsidian KB - 本地文件源"""
import os
from pathlib import Path
from typing import Optional
from .base import BaseSource
from ..models import Content, ContentType
class LocalSource(BaseSource):
"""本地文件源"""
name = "local"
SUPPORTED_EXTENSIONS = ['.txt', '.md', '.pdf', '.docx']
def can_handle(self, url: str) -> bool:
"""检查是否为本地文件路径"""
if url.startswith('http'):
return False
path = Path(url)
return path.exists() and path.suffix.lower() in self.SUPPORTED_EXTENSIONS
def extract_id(self, url: str) -> Optional[str]:
"""使用文件路径作为ID"""
return os.path.basename(url)
def fetch(self, url: str) -> Optional[Content]:
"""获取文件元数据"""
path = Path(url)
if not path.exists():
return None
content_type = ContentType.ARTICLE
if path.suffix.lower() == '.txt':
content_type = ContentType.ARTICLE
return Content(
source=self.name,
source_id=str(path),
url=str(path.absolute()),
title=path.stem,
author='',
content_type=content_type
)
def get_transcript(self, content: Content) -> Optional[str]:
"""读取文件内容"""
try:
path = Path(content.url)
if path.suffix.lower() in ['.txt', '.md']:
return path.read_text(encoding='utf-8')
# TODO: 支持 PDF 和 DOCX
return None
except Exception as e:
print(f"Error reading local file: {e}")
return None
@@ -1,17 +0,0 @@
from markdownify import MarkdownConverter as _MarkdownConverter
class MarkdownConverter:
"""HTML 转 Markdown 转换器,保留完整格式"""
def __init__(self):
self.converter = _MarkdownConverter(
heading_style="ATX",
bullets="*,-",
code_blocks=True,
tables=True,
strip=["script", "style", "nav", "footer", "aside"]
)
def convert(self, html: str) -> str:
"""转换 HTML 为 Markdown"""
return self.converter.convert(html)
-53
View File
@@ -1,53 +0,0 @@
"""Obsidian KB - YouTube源"""
import re
from typing import Optional
from .base import BaseSource
from ..models import Content, ContentType
class YouTubeSource(BaseSource):
"""YouTube视频源"""
name = "youtube"
# 支持多种YouTube URL格式
URL_PATTERNS = [
r'youtube\.com/watch\?v=([a-zA-Z0-9_-]{11})',
r'youtu\.be/([a-zA-Z0-9_-]{11})',
r'youtube\.com/embed/([a-zA-Z0-9_-]{11})',
]
def can_handle(self, url: str) -> bool:
"""检查是否为YouTube链接"""
return 'youtube.com' in url or 'youtu.be' in url
def extract_id(self, url: str) -> Optional[str]:
"""提取视频ID"""
for pattern in self.URL_PATTERNS:
match = re.search(pattern, url)
if match:
return match.group(1)
return None
def fetch(self, url: str) -> Optional[Content]:
"""获取视频元数据"""
video_id = self.extract_id(url)
if not video_id:
return None
# TODO: 使用 YouTube Data API 获取完整信息
# 目前返回基本信息
return Content(
source=self.name,
source_id=video_id,
url=f'https://www.youtube.com/watch?v={video_id}',
title='', # 需要通过API获取
author='',
content_type=ContentType.VIDEO
)
def get_transcript(self, content: Content) -> Optional[str]:
"""获取字幕"""
# TODO: 使用 youtube-transcript-api
return None
-91
View File
@@ -1,91 +0,0 @@
"""Obsidian KB - 知乎源"""
import re
from typing import Optional
from .base import BaseSource
from ..models import Content, ContentType
try:
from .zhihu_drission_extractor import ZhihuDrissionExtractor
except ImportError:
# DrissionPage 未安装
import logging
logger = logging.getLogger(__name__)
logger.warning(f"DrissionPage 未安装,无法提取知乎内容")
class ZhihuSource(BaseSource):
"""知乎文章/回答源"""
name = "zhihu"
URL_PATTERNS = [
r'zhihu\.com/question/\d+/answer/(\d+)',
r'zhuanlan\.zhihu\.com/p/(\d+)',
r'zhihu\.com/question/(\d+)',
]
def can_handle(self, url: str) -> bool:
"""检查是否为知乎链接"""
return 'zhihu.com' in url or 'zhuanlan.zhihu.com' in url
def extract_id(self, url: str) -> Optional[str]:
"""提取内容ID"""
for pattern in self.URL_PATTERNS:
match = re.search(pattern, url)
if match:
return match.group(1)
return None
def fetch(self, url: str) -> Optional[Content]:
"""获取内容元数据"""
content_id = self.extract_id(url)
if not content_id:
return None
# TODO: 使用知乎API或爬虫获取完整信息
content_type = ContentType.ARTICLE
if 'zhuanlan' in url:
content_type = ContentType.ARTICLE
return Content(
source=self.name,
source_id=content_id,
url=url,
title='',
author='',
content_type=content_type
)
def get_transcript(self, content: Content) -> Optional[str]:
"""获取文章内容"""
try:
# 使用 DrissionPage 提取器
extractor = ZhihuDrissionExtractor(
headless=self.config.get('headless', True),
timeout=self.config.get('timeout', 30),
config=self.config
)
data = extractor.extract(content.url)
if data and data.get('content_markdown'):
# 更新元数据
if data.get('title'):
content.title = data['title']
if data.get('author'):
content.author = data['author']
# 返回 Markdown 内容
return data['content_markdown']
except ImportError as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"DrissionPage 导入失败: {e}")
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"DrissionPage 提取失败: {e}")
return None
@@ -1,514 +0,0 @@
"""
知乎 DrissionPage 内容提取器
使用 DrissionPage 浏览器自动化工具抓取知乎内容
"""
import re
import json
import sys
import os
from typing import Dict, Optional, Any, List
from pathlib import Path
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 DrissionBrowser:
"""DrissionPage 浏览器管理器"""
def __init__(self, headless: bool = True, timeout: int = 30):
self.headless = headless
self.timeout = timeout
self.page = None
def start(self):
"""启动浏览器"""
from DrissionPage import ChromiumOptions, ChromiumPage
options = ChromiumOptions()
# 设置浏览器路径(服务器环境需要)
options.set_browser_path('/usr/bin/chromium-browser')
if self.headless:
options.headless()
options.set_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 = ChromiumPage(options)
self.page.set.timeouts(base=self.timeout)
def close(self):
"""关闭浏览器"""
if self.page:
self.page.quit()
self.page = None
def goto(self, url: str):
"""导航到指定 URL"""
self.page.get(url)
def wait_for_selector(self, selector: str, timeout: int = None):
"""等待选择器出现"""
timeout = timeout or self.timeout
self.page.wait.ele_displayed(selector, timeout=timeout)
def query_selector(self, selector: str):
"""查询选择器"""
return self.page.ele(selector)
def query_selector_all(self, selector: str):
"""查询所有匹配的选择器"""
return self.page.eles(selector)
def evaluate(self, script: str):
"""执行 JavaScript 代码"""
return self.page.run_js(script)
def get_page_title(self):
"""获取页面标题"""
if self.page:
return self.page.title
return ''
def get_page_content(self):
"""获取页面 HTML 内容"""
if self.page:
return self.page.html
return ''
def screenshot(self, path: str):
"""截图"""
if self.page:
self.page.get_screenshot(path=path)
class ZhihuDrissionExtractor:
"""知乎 DrissionPage 提取器"""
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, config: Optional[Dict] = None):
"""
初始化提取器
Args:
headless: 是否使用无头浏览器(默认 True)
timeout: 超时时间(秒,默认 30)
config: 配置字典,用于加载 Cookie
"""
self.headless = headless
self.timeout = timeout
self.config = config or {}
# 支持从 config 中获取 headless 和 timeout
if self.config.get('headless') is not None:
self.headless = self.config.get('headless')
if self.config.get('timeout') is not None:
self.timeout = self.config.get('timeout')
self.browser = DrissionBrowser(headless=self.headless, timeout=self.timeout)
self.markdown_converter = MarkdownConverter()
# 从配置中读取 Cookie 文件路径,支持环境变量展开
# 直接读取 cookie_file(因为 self.config 已经是 zhihu 配置)
cookie_file = self.config.get('cookie_file')
if cookie_file:
self.cookie_file = os.path.expanduser(cookie_file)
else:
# 默认路径
self.cookie_file = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'data', 'cookies', 'zhihu.json'
)
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.text if hasattr(element, 'text') else ''
def _extract_html_content(self, element) -> str:
"""提取元素的 HTML 内容"""
if element is None:
return ''
return element.html if hasattr(element, 'html') else ''
def _extract_attribute(self, selector: str, attribute: str) -> str:
"""提取元素的属性"""
try:
element = self.browser.query_selector(selector)
if element:
return element.attr(attribute) or ''
except Exception as e:
print(f"提取属性时出错(选择器: {selector}, 属性: {attribute}): {e}")
return ''
def _load_cookies(self) -> List[Dict]:
"""
加载 Cookie,优先级:配置 > 环境 > 文件
Returns:
Cookie 列表
"""
cookies = []
# 1. 尝试从配置加载(优先级最高)
# 支持扁平和嵌套配置结构
cookie_str = self.config.get('cookie_str')
if cookie_str:
try:
cookies = json.loads(cookie_str)
print(f"从配置加载 Cookie,共 {len(cookies)} 个")
except json.JSONDecodeError as e:
print(f"配置中的 Cookie JSON 格式错误: {e}")
# 2. 尝试从环境变量加载(优先级中等)
elif os.getenv('ZHIHU_COOKIE'):
try:
cookies = json.loads(os.getenv('ZHIHU_COOKIE'))
print(f"从环境变量加载 Cookie,共 {len(cookies)} 个")
except json.JSONDecodeError as e:
print(f"环境变量中的 Cookie JSON 格式错误: {e}")
# 3. 尝试从文件加载(优先级最低)
else:
# 直接从配置中读取 cookie_file(因为 self.config 已经是 zhihu 配置)
cookie_file = self.config.get('cookie_file')
if not cookie_file:
# 如果没有配置,使用默认路径
cookie_file = self.cookie_file
else:
# 展开环境变量(如 ~)
cookie_file = os.path.expanduser(cookie_file)
if cookie_file and Path(cookie_file).exists():
try:
with open(cookie_file, 'r', encoding='utf-8') as f:
cookies = json.load(f)
print(f"从文件加载 Cookie ({cookie_file}),共 {len(cookies)} 个")
except (json.JSONDecodeError, FileNotFoundError, IOError) as e:
print(f"从文件加载 Cookie 失败: {e}")
else:
print(f"未找到 Cookie 配置,将使用未登录状态访问知乎")
print(f" 期望路径: {cookie_file}")
return cookies
def _apply_cookies(self, cookies: List[Dict]):
"""
应用 Cookie 到浏览器页面
Args:
cookies: Cookie 列表
"""
if not cookies:
return
try:
for cookie in cookies:
if self.browser and self.browser.page:
self.browser.page.set.cookies.add(cookie)
print(f"成功应用 {len(cookies)} 个 Cookie")
except Exception as e:
print(f"应用 Cookie 时出错: {e}")
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.get_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.screenshot(os.path.join(debug_dir, 'zhihu_drission_screenshot.png'))
with open(os.path.join(debug_dir, 'zhihu_drission_page.html'), 'w', encoding='utf-8') as f:
f.write(self.browser.get_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.attr('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.attr('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,
'url': str,
'content_id': str,
}
如果提取失败返回 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}")
# 加载 Cookie
cookies = self._load_cookies()
if cookies:
print(f"加载了 {len(cookies)} 个 Cookie")
print(f"启动浏览器...")
# 启动浏览器
self.browser.start()
# 应用 Cookie
if cookies and self.browser.page:
self._apply_cookies(cookies)
print(f"访问页面: {url}")
# 访问页面
self.browser.goto(url)
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, config=None) -> Optional[Dict[str, Any]]:
"""
便捷函数:提取知乎内容
Args:
url: 知乎 URL
config: 配置字典
Returns:
提取的内容字典
"""
with ZhihuDrissionExtractor(config=config) as extractor:
return extractor.extract(url)
@@ -1,385 +0,0 @@
"""
知乎 Playwright 内容提取器
使用 Playwright 浏览器自动化工具抓取知乎内容
"""
import re
import sys
import os
from typing import Dict, Optional, Any
from urllib.parse import urlparse
# 添加当前目录到 Python 路径以支持绝对导入
current_dir = os.path.dirname(os.path.abspath(__file__))
if current_dir not in sys.path:
sys.path.insert(0, current_dir)
try:
from .markdown_converter import MarkdownConverter
except ImportError:
from markdown_converter import MarkdownConverter
class PlaywrightBrowser:
"""Playwright 浏览器管理器"""
def __init__(self, headless: bool = True, timeout: int = 30):
self.headless = headless
self.timeout = timeout * 1000
self.browser = None
self.context = None
self.page = None
def start(self):
"""启动浏览器"""
from playwright.sync_api import sync_playwright
self.playwright = sync_playwright().start()
self.browser = self.playwright.chromium.launch(headless=self.headless)
self.context = self.browser.new_context(
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'
)
self.page = self.context.new_page()
self.page.set_default_timeout(self.timeout)
def close(self):
"""关闭浏览器"""
if self.page:
self.page.close()
if self.context:
self.context.close()
if self.browser:
self.browser.close()
if hasattr(self, 'playwright'):
self.playwright.stop()
def goto(self, url: str, wait_until: str = "networkidle"):
"""导航到指定URL"""
return self.page.goto(url, wait_until=wait_until)
def wait_for_selector(self, selector: str, timeout: int = None):
"""等待选择器出现"""
timeout_ms = timeout * 1000 if timeout else self.timeout
return self.page.wait_for_selector(selector, timeout=timeout_ms)
def query_selector(self, selector: str):
"""查询选择器"""
return self.page.query_selector(selector)
def query_selector_all(self, selector: str):
"""查询所有匹配的选择器"""
return self.page.query_selector_all(selector)
def evaluate(self, script: str):
"""执行 JavaScript 代码"""
return self.page.evaluate(script)
class ZhihuPlaywrightExtractor:
"""知乎内容提取器 - 使用 Playwright"""
URL_PATTERNS = {
'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)
-168
View File
@@ -1,168 +0,0 @@
"""Obsidian KB - 存储管理"""
import os
import json
from pathlib import Path
from typing import List, Optional, Dict, Any
from datetime import datetime
from models import Content, ContentStatus
from config import get_config
class Storage:
"""JSON文件存储管理"""
def __init__(self, base_path: Optional[str] = None):
config = get_config()
self.base_path = base_path or config.obsidian_path
self.inbox_path = os.path.join(self.base_path, 'Inbox')
self.topics_path = os.path.join(self.base_path, 'Topics')
self._ensure_dirs()
def _ensure_dirs(self) -> None:
"""确保目录存在"""
os.makedirs(self.inbox_path, exist_ok=True)
os.makedirs(self.topics_path, exist_ok=True)
def _content_file_path(self, content_id: str) -> str:
"""获取内容文件路径"""
return os.path.join(self.inbox_path, f'{content_id}.json')
def _topic_index_path(self, topic: str) -> str:
"""获取主题索引路径"""
topic_dir = os.path.join(self.topics_path, topic)
os.makedirs(topic_dir, exist_ok=True)
return os.path.join(topic_dir, 'index.json')
# ===== Inbox 管理 =====
def save_content(self, content: Content) -> None:
"""保存内容到Inbox"""
file_path = self._content_file_path(content.id)
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(content.to_dict(), f, ensure_ascii=False, indent=2)
def load_content(self, content_id: str) -> Optional[Content]:
"""加载指定内容"""
file_path = self._content_file_path(content_id)
if not os.path.exists(file_path):
return None
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return Content.from_dict(data)
def delete_content(self, content_id: str) -> bool:
"""删除内容"""
file_path = self._content_file_path(content_id)
if os.path.exists(file_path):
os.remove(file_path)
return True
return False
def list_inbox(self, status: Optional[ContentStatus] = None) -> List[Content]:
"""列出Inbox中的内容"""
contents = []
if not os.path.exists(self.inbox_path):
return contents
for filename in os.listdir(self.inbox_path):
if filename.endswith('.json'):
file_path = os.path.join(self.inbox_path, filename)
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
content = Content.from_dict(data)
if status is None or content.status == status:
contents.append(content)
# 按创建时间排序
contents.sort(key=lambda x: x.created_at, reverse=True)
return contents
def get_pending_count(self) -> int:
"""获取待处理内容数量"""
return len(self.list_inbox(ContentStatus.CONFIRMED))
def get_stats(self) -> Dict[str, int]:
"""获取统计信息"""
all_contents = self.list_inbox()
stats = {status.value: 0 for status in ContentStatus}
for content in all_contents:
stats[content.status.value] += 1
return stats
# ===== Topics 管理 =====
def add_to_topic(self, content: Content, topic: str) -> None:
"""将内容添加到主题"""
index_path = self._topic_index_path(topic)
# 读取现有索引
entries = []
if os.path.exists(index_path):
with open(index_path, 'r', encoding='utf-8') as f:
entries = json.load(f)
# 添加新条目
entry = {
'id': content.id,
'title': content.title,
'url': content.url,
'added_at': datetime.now().isoformat(),
'output_path': content.output_path
}
# 避免重复
entries = [e for e in entries if e['id'] != content.id]
entries.append(entry)
# 保存索引
with open(index_path, 'w', encoding='utf-8') as f:
json.dump(entries, f, ensure_ascii=False, indent=2)
def get_topic_contents(self, topic: str) -> List[Dict[str, Any]]:
"""获取主题下的所有内容"""
index_path = self._topic_index_path(topic)
if not os.path.exists(index_path):
return []
with open(index_path, 'r', encoding='utf-8') as f:
return json.load(f)
def list_topics(self) -> List[str]:
"""列出所有主题"""
if not os.path.exists(self.topics_path):
return []
return [d for d in os.listdir(self.topics_path)
if os.path.isdir(os.path.join(self.topics_path, d))]
def search(self, query: str) -> List[Content]:
"""搜索内容"""
results = []
all_contents = self.list_inbox()
query_lower = query.lower()
for content in all_contents:
if (query_lower in content.title.lower() or
query_lower in content.summary.lower() or
query_lower in content.author.lower() or
any(query_lower in tag.lower() for tag in content.tags)):
results.append(content)
return results
def move_to_archive(self, content_id: str) -> bool:
"""将内容移动到归档"""
content = self.load_content(content_id)
if not content:
return False
archive_path = os.path.join(self.base_path, 'Archive')
os.makedirs(archive_path, exist_ok=True)
source_file = self._content_file_path(content_id)
target_file = os.path.join(archive_path, f'{content_id}.json')
if os.path.exists(source_file):
os.rename(source_file, target_file)
return True
return False
-71
View File
@@ -1,71 +0,0 @@
"""Template engine for note generation."""
from typing import Any, Dict
from pathlib import Path
class TemplateEngine:
"""Manages templates for Obsidian note generation."""
def __init__(self, templates_dir: str = None):
"""Initialize template engine.
Args:
templates_dir: Directory containing template files
"""
if templates_dir is None:
base_dir = Path(__file__).parent.parent
templates_dir = base_dir / 'src' / 'templates'
self.templates_dir = Path(templates_dir)
self.templates_dir.mkdir(parents=True, exist_ok=True)
def render(self, template_name: str, context: Dict[str, Any]) -> str:
"""Render a template with context.
Args:
template_name: Name of template to use
context: Template context variables
Returns:
Rendered template string
"""
template_path = self.templates_dir / f"{template_name}.md"
if template_path.exists():
template = template_path.read_text(encoding='utf-8')
else:
# Default template
template = self._default_template()
# Simple variable substitution
result = template
for key, value in context.items():
placeholder = f"{{{{{key}}}}}"
result = result.replace(placeholder, str(value))
return result
def _default_template(self) -> str:
"""Get default note template."""
return """# {{{title}}}
{{tags}}
## Summary
{{summary}}
## Original Content
**Source:** [{{platform}}]({{url}})
**ID:** {{content_id}}
---
{{content}}
## Notes
<!-- Add your notes here -->
"""
-307
View File
@@ -1,307 +0,0 @@
"""
Template Renderer Module
Handles rendering of content into Markdown templates using Jinja2.
"""
import os
from datetime import datetime
from typing import Dict, Any, Optional
from pathlib import Path
# Try to import jinja2, fallback to simple string formatting
try:
from jinja2 import Template, Environment, FileSystemLoader
JINJA2_AVAILABLE = True
except ImportError:
JINJA2_AVAILABLE = False
Template = None
Environment = None
FileSystemLoader = None
class TemplateRenderer:
"""
Renders content objects into Markdown templates.
Supports both Jinja2 and simple string formatting.
"""
def __init__(self, templates_dir: Optional[str] = None):
"""
Initialize the template renderer.
Args:
templates_dir: Path to templates directory. If None, uses default.
"""
if templates_dir is None:
# Default to templates directory next to this file
self.templates_dir = Path(__file__).parent / "templates"
else:
self.templates_dir = Path(templates_dir)
self.templates_dir.mkdir(parents=True, exist_ok=True)
# Initialize Jinja2 environment if available
if JINJA2_AVAILABLE:
self.env = Environment(
loader=FileSystemLoader(str(self.templates_dir)),
trim_blocks=True,
lstrip_blocks=True
)
else:
self.env = None
def _get_template_name(self, content: Any) -> str:
"""
Determine which template to use based on content type and duration.
Args:
content: Content object with content_type and duration attributes
Returns:
Template filename
"""
content_type = getattr(content, 'content_type', None)
duration = getattr(content, 'duration', 0)
# Handle enum types
if hasattr(content_type, 'value'):
content_type = content_type.value
# Convert duration to seconds if it's a string
if isinstance(duration, str):
try:
parts = duration.split(':')
if len(parts) == 2: # MM:SS
duration = int(parts[0]) * 60 + int(parts[1])
elif len(parts) == 3: # HH:MM:SS
duration = int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
else:
duration = 0
except (ValueError, IndexError):
duration = 0
# Select template based on type and duration
if content_type == 'article':
return 'article.md'
elif content_type == 'video':
# Long videos (>>30 min) use tutorial template
if duration and duration > 1800: # 30 minutes
return 'tutorial_video.md'
else:
return 'light_video.md'
elif duration and duration > 1800:
return 'tutorial_video.md'
else:
return 'light_video.md'
def _prepare_context(self, content: Any, extra_context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Prepare the context dictionary for template rendering.
Args:
content: Content object
extra_context: Additional context to merge
Returns:
Context dictionary
"""
# Get analysis data if available
analysis_data = {}
if hasattr(content, 'analysis') and content.analysis:
if isinstance(content.analysis, dict):
analysis_data = content.analysis
else:
# It's a ContentAnalysis object
analysis_data = {
'one_liner': getattr(content.analysis, 'one_liner', ''),
'summary': getattr(content.analysis, 'summary', ''),
'key_points': getattr(content.analysis, 'key_points', []),
'tags': getattr(content.analysis, 'tags', []),
'quality_score': getattr(content.analysis, 'quality_score', 0),
'prerequisites': getattr(content.analysis, 'prerequisites', []),
'outline': getattr(content.analysis, 'outline', []),
'pitfalls': getattr(content.analysis, 'pitfalls', []),
'practice_advice': getattr(content.analysis, 'practice_advice', []),
}
# Base context from content object
context = {
'title': getattr(content, 'title', 'Untitled'),
'source': getattr(content, 'source', 'unknown'),
'url': getattr(content, 'url', ''),
'author': getattr(content, 'author', 'Unknown'),
'duration': getattr(content, 'duration', ''),
'date': getattr(content, 'date', datetime.now().isoformat()),
'tags': getattr(content, 'tags', ['untagged']),
# First check analysis_data, then fall back to content attributes
'one_liner': analysis_data.get('one_liner', getattr(content, 'one_liner', '')),
'summary': analysis_data.get('summary', getattr(content, 'summary', '')),
'key_points': analysis_data.get('key_points', getattr(content, 'key_points', [])),
'quality_score': analysis_data.get('quality_score', getattr(content, 'quality_score', 0)),
'prerequisites': analysis_data.get('prerequisites', []),
'outline': analysis_data.get('outline', []),
'pitfalls': analysis_data.get('pitfalls', []),
'practice_advice': analysis_data.get('practice_advice', []),
}
# Add transcript-related data
if hasattr(content, 'transcript'):
context['transcript'] = content.transcript
# Merge extra context
if extra_context:
context.update(extra_context)
# Ensure tags is a list
if isinstance(context['tags'], str):
context['tags'] = [tag.strip() for tag in context['tags'].split(',')]
if not context['tags']:
context['tags'] = ['untagged']
return context
def render(self, content: Any, extra_context: Optional[Dict[str, Any]] = None) -> str:
"""
Render content into Markdown using appropriate template.
Args:
content: Content object to render
extra_context: Additional context for template
Returns:
Rendered Markdown string
"""
template_name = self._get_template_name(content)
context = self._prepare_context(content, extra_context)
template_path = self.templates_dir / template_name
# Check if template exists
if not template_path.exists():
raise FileNotFoundError(f"Template not found: {template_path}")
# Read template content
template_content = template_path.read_text(encoding='utf-8')
# Render using Jinja2 if available
if JINJA2_AVAILABLE and self.env:
template = self.env.from_string(template_content)
return template.render(**context)
else:
# Fallback to simple string formatting
return self._simple_render(template_content, context)
def _simple_render(self, template_content: str, context: Dict[str, Any]) -> str:
"""
Simple string-based template rendering fallback.
Supports basic {{ variable }} and {% for %} loops.
Args:
template_content: Template string
context: Context dictionary
Returns:
Rendered string
"""
result = template_content
# Handle simple for loops (very basic implementation)
import re
# Pattern for {% for item in items %}...{% endfor %}
for_pattern = r'{%-?\s*for\s+(\w+)\s+in\s+(\w+)\s*-?%}(.*?){%-?\s*endfor\s*-?%}'
def replace_for(match):
var_name = match.group(1)
list_name = match.group(2)
inner_template = match.group(3)
items = context.get(list_name, [])
if not items:
return ''
output = []
for item in items:
item_str = inner_template
# Handle {{ var.attribute }} or {{ var }}
if isinstance(item, dict):
for key, value in item.items():
placeholder1 = f'{{{{{var_name}.{key}}}}}'
placeholder2 = f'{{{{ {var_name}.{key} }}}}'
str_val = str(value) if value is not None else ''
item_str = item_str.replace(placeholder1, str_val)
item_str = item_str.replace(placeholder2, str_val)
else:
placeholder1 = f'{{{{{var_name}}}}}'
placeholder2 = f'{{{{ {var_name} }}}}'
str_val = str(item) if item is not None else ''
item_str = item_str.replace(placeholder1, str_val)
item_str = item_str.replace(placeholder2, str_val)
output.append(item_str)
return ''.join(output)
result = re.sub(for_pattern, replace_for, result, flags=re.DOTALL)
# Handle simple if statements (basic)
if_pattern = r'{%-?\s*if\s+(\w+)\s*-?%}(.*?){%-?\s*endif\s*-?%}'
def replace_if(match):
var_name = match.group(1)
inner_content = match.group(2)
if context.get(var_name):
return inner_content
return ''
result = re.sub(if_pattern, replace_if, result, flags=re.DOTALL)
# Handle variable substitution {{ variable }} or {{variable}}
# Sort keys by length (descending) to avoid partial replacements
for var_name in sorted(context.keys(), key=len, reverse=True):
value = context[var_name]
if isinstance(value, list):
str_val = ', '.join(str(v) for v in value)
else:
str_val = str(value) if value is not None else ''
# Replace both {{ var }} and {{var}}
result = result.replace(f'{{{{ {var_name} }}}}', str_val)
result = result.replace(f'{{{{{var_name}}}}}', str_val)
return result
def render_to_file(self, content: Any, output_path: str, extra_context: Optional[Dict[str, Any]] = None) -> str:
"""
Render content and save to file.
Args:
content: Content object to render
output_path: Path to output file
extra_context: Additional context for template
Returns:
Path to rendered file
"""
rendered = self.render(content, extra_context)
output_file = Path(output_path)
output_file.parent.mkdir(parents=True, exist_ok=True)
output_file.write_text(rendered, encoding='utf-8')
return str(output_file)
# Convenience function
def render_content(content: Any, templates_dir: Optional[str] = None, extra_context: Optional[Dict[str, Any]] = None) -> str:
"""
Convenience function to render content without creating a renderer instance.
Args:
content: Content object to render
templates_dir: Optional templates directory path
extra_context: Additional context for template
Returns:
Rendered Markdown string
"""
renderer = TemplateRenderer(templates_dir)
return renderer.render(content, extra_context)
@@ -1,33 +0,0 @@
---
title: "{{ title }}"
source: "{{ source }}"
url: "{{ url }}"
author: "{{ author }}"
date: "{{ date }}"
tags:{% for tag in tags %}
- {{ tag }}{% endfor %}
quality_score: {{ quality_score }}
reading_time: {{ reading_time }}
---
# {{ title }}
**原文链接**: [{{ title }}]({{ url }})
## 🎯 一句话总结
{{ one_liner }}
## 📖 文章摘要
{{ summary }}
## 💡 核心观点{% for point in key_points %}
1. {{ point }}{% endfor %}
## 📝 详细内容
{{ content }}
## 🔗 相关链接{% for link in related_links %}
- [{{ link.title }}]({{ link.url }}){% endfor %}
## 🏷️ 标签
{% for tag in tags %}#{{ tag }} {% endfor %}
@@ -1,20 +0,0 @@
---
title: "{{ title }}"
source: "{{ source }}"
url: "{{ url }}"
author: "{{ author }}"
duration: "{{ duration }}"
date: "{{ date }}"
tags:{% for tag in tags %}
- {{ tag }}{% endfor %}
---
#{{ tags[0] }} #{{ tags[1] }}
**一句话摘要**: {{ one_liner }}
## 摘要
{{ summary }}
## 核心要点{% for point in key_points %}
1. {{ point }}{% endfor %}
@@ -1,16 +0,0 @@
---
title: {{topic_name}}
date: {{date}}
tags: [topic-index]
---
# {{topic_name}}
## 简介
{{description}}
## 相关内容
{{content_list}}
## 学习路径
{{learning_path}}
@@ -1,35 +0,0 @@
---
title: "{{ title }}"
source: "{{ source }}"
url: "{{ url }}"
up主: "{{ author }}"
duration: "{{ duration }}"
date: "{{ date }}"
tags:{% for tag in tags %}
- {{ tag }}{% endfor %}
quality_score: {{ quality_score }}
---
## 🎯 一句话总结
{{ one_liner }}
## 📚 前置知识{% for pre in prerequisites %}
- {{ pre }}{% endfor %}
## 📝 步骤详解{% for section in outline %}
### {{ section.title }} [{{ section.time_range }}]
{{ section.summary }}
{% if section.code_snippet %}
```{{ section.code_language }}
{{ section.code_snippet }}
```
{% endif %}{% endfor %}
## 💡 关键要点{% for point in key_points %}
1. {{ point }}{% endfor %}
## ⚠️ 常见坑点{% for pitfall in pitfalls %}
- {{ pitfall }}{% endfor %}
## 🤔 实践建议{% for advice in practice_advice %}
- {{ advice }}{% endfor %}