refactor: 删除多余的历史文件,保留核心kb_workflow.py
This commit is contained in:
@@ -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": [
|
||||
"📭 收件箱为空,暂无待处理内容"
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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'
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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']
|
||||
@@ -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']
|
||||
@@ -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)
|
||||
@@ -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']
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 -->
|
||||
"""
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user