From f66a50cd3f6c7ec7452702eb2c2fbbebcef48efe Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Tue, 10 Feb 2026 17:34:54 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=A4=8D=E5=88=B6=20obsidian-kb=20?= =?UTF-8?q?=E7=9A=84=20src=20=E7=9B=AE=E5=BD=95=E4=BB=A5=E8=A7=A3=E5=86=B3?= =?UTF-8?q?=E4=BE=9D=E8=B5=96=E7=BC=BA=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/kb-workflow/src/__init__.py | 36 + skills/kb-workflow/src/ai_client.py | 514 ++++++++++++ skills/kb-workflow/src/cli.py | 569 +++++++++++++ skills/kb-workflow/src/config.py | 353 +++++++++ skills/kb-workflow/src/content_chunker.py | 486 ++++++++++++ skills/kb-workflow/src/deep_analyzer.py | 438 ++++++++++ skills/kb-workflow/src/extractors/__init__.py | 18 + skills/kb-workflow/src/extractors/base.py | 83 ++ .../src/extractors/bilibili_extractor.py | 750 ++++++++++++++++++ .../src/extractors/easyocr_processor.py | 355 +++++++++ .../src/extractors/hard_subtitle_extractor.py | 435 ++++++++++ .../src/extractors/ocr_processor.py | 309 ++++++++ .../src/extractors/subtitle_detector.py | 383 +++++++++ .../src/extractors/video_stream_extractor.py | 517 ++++++++++++ skills/kb-workflow/src/git_sync.py | 195 +++++ skills/kb-workflow/src/light_scanner.py | 228 ++++++ skills/kb-workflow/src/llm.py | 60 ++ skills/kb-workflow/src/models.py | 213 +++++ skills/kb-workflow/src/outputs/__init__.py | 5 + skills/kb-workflow/src/outputs/git_sync.py | 275 +++++++ .../src/outputs/obsidian_writer.py | 385 +++++++++ skills/kb-workflow/src/pipeline.py | 273 +++++++ skills/kb-workflow/src/sources/__init__.py | 31 + skills/kb-workflow/src/sources/base.py | 58 ++ skills/kb-workflow/src/sources/bilibili.py | 466 +++++++++++ skills/kb-workflow/src/sources/local.py | 57 ++ .../src/sources/markdown_converter.py | 17 + skills/kb-workflow/src/sources/youtube.py | 53 ++ skills/kb-workflow/src/sources/zhihu.py | 91 +++ .../src/sources/zhihu_drission_extractor.py | 514 ++++++++++++ .../src/sources/zhihu_playwright_extractor.py | 385 +++++++++ skills/kb-workflow/src/storage.py | 168 ++++ skills/kb-workflow/src/template.py | 71 ++ skills/kb-workflow/src/template_renderer.py | 307 +++++++ skills/kb-workflow/src/templates/article.md | 33 + .../kb-workflow/src/templates/light_video.md | 20 + .../kb-workflow/src/templates/topic_index.md | 16 + .../src/templates/tutorial_video.md | 35 + 38 files changed, 9202 insertions(+) create mode 100644 skills/kb-workflow/src/__init__.py create mode 100644 skills/kb-workflow/src/ai_client.py create mode 100644 skills/kb-workflow/src/cli.py create mode 100644 skills/kb-workflow/src/config.py create mode 100644 skills/kb-workflow/src/content_chunker.py create mode 100644 skills/kb-workflow/src/deep_analyzer.py create mode 100644 skills/kb-workflow/src/extractors/__init__.py create mode 100644 skills/kb-workflow/src/extractors/base.py create mode 100644 skills/kb-workflow/src/extractors/bilibili_extractor.py create mode 100644 skills/kb-workflow/src/extractors/easyocr_processor.py create mode 100644 skills/kb-workflow/src/extractors/hard_subtitle_extractor.py create mode 100644 skills/kb-workflow/src/extractors/ocr_processor.py create mode 100644 skills/kb-workflow/src/extractors/subtitle_detector.py create mode 100644 skills/kb-workflow/src/extractors/video_stream_extractor.py create mode 100644 skills/kb-workflow/src/git_sync.py create mode 100644 skills/kb-workflow/src/light_scanner.py create mode 100644 skills/kb-workflow/src/llm.py create mode 100644 skills/kb-workflow/src/models.py create mode 100644 skills/kb-workflow/src/outputs/__init__.py create mode 100644 skills/kb-workflow/src/outputs/git_sync.py create mode 100644 skills/kb-workflow/src/outputs/obsidian_writer.py create mode 100644 skills/kb-workflow/src/pipeline.py create mode 100644 skills/kb-workflow/src/sources/__init__.py create mode 100644 skills/kb-workflow/src/sources/base.py create mode 100644 skills/kb-workflow/src/sources/bilibili.py create mode 100644 skills/kb-workflow/src/sources/local.py create mode 100644 skills/kb-workflow/src/sources/markdown_converter.py create mode 100644 skills/kb-workflow/src/sources/youtube.py create mode 100644 skills/kb-workflow/src/sources/zhihu.py create mode 100644 skills/kb-workflow/src/sources/zhihu_drission_extractor.py create mode 100644 skills/kb-workflow/src/sources/zhihu_playwright_extractor.py create mode 100644 skills/kb-workflow/src/storage.py create mode 100644 skills/kb-workflow/src/template.py create mode 100644 skills/kb-workflow/src/template_renderer.py create mode 100644 skills/kb-workflow/src/templates/article.md create mode 100644 skills/kb-workflow/src/templates/light_video.md create mode 100644 skills/kb-workflow/src/templates/topic_index.md create mode 100644 skills/kb-workflow/src/templates/tutorial_video.md diff --git a/skills/kb-workflow/src/__init__.py b/skills/kb-workflow/src/__init__.py new file mode 100644 index 0000000..e99d35c --- /dev/null +++ b/skills/kb-workflow/src/__init__.py @@ -0,0 +1,36 @@ +"""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' diff --git a/skills/kb-workflow/src/ai_client.py b/skills/kb-workflow/src/ai_client.py new file mode 100644 index 0000000..cee3c73 --- /dev/null +++ b/skills/kb-workflow/src/ai_client.py @@ -0,0 +1,514 @@ +"""Obsidian KB - AI客户端 +支持多个 AI 提供商和 fallback 机制 +""" +import os +import json +import requests +from typing import Dict, Any, List, Optional, Tuple +from src.models import ContentType +from src.config import get_config +from src.content_chunker import ContentChunker, get_chunking_strategy, chunk_content + + +class AIClient: + """ + AI客户端 - 支持分段分析长视频内容和 fallback 机制 + """ + + def __init__(self): + self.config = get_config() + self.chunker = ContentChunker() + # 获取 AI 配置 + self._load_ai_config() + + def _load_ai_config(self) -> None: + """加载 AI 配置(主 provider + fallbacks)""" + self.ai_configs = [] + + # 加载主配置 + main_provider = self.config.get('ai.provider', 'openai') + main_config = { + 'provider': main_provider, + 'api_key': self.config.get('ai.api_key'), + 'model': self.config.get('ai.model', 'gpt-4'), + 'base_url': self.config.get('ai.base_url'), + 'is_primary': True + } + self.ai_configs.append(main_config) + + # 加载 fallback 配置 + fallbacks = self.config.get('ai.fallbacks', []) + for fb in fallbacks: + self.ai_configs.append({ + 'provider': fb.get('provider'), + 'api_key': fb.get('api_key'), + 'model': fb.get('model'), + 'base_url': fb.get('base_url'), + 'is_primary': False + }) + + def _call_api_with_fallback(self, prompt: str, max_tokens: int = 1000, response_format: str = 'text') -> Tuple[bool, str]: + """ + 使用 fallback 机制调用 API + + Args: + prompt: 提示词 + max_tokens: 最大 token 数 + response_format: 响应格式 ('text' 或 'json') + + Returns: + (success, response) 元组 + """ + last_error = None + + for i, ai_config in enumerate(self.ai_configs): + try: + if not ai_config.get('api_key'): + print(f"[AIClient] 跳过 {ai_config.get('provider')} - API Key 为空") + continue + + provider_type = ai_config.get('provider') + print(f"[AIClient] 尝试使用 {provider_type} (优先级 {i+1}/{len(self.ai_configs)})...") + + response = self._call_single_provider(ai_config, prompt, max_tokens, response_format) + return True, response + + except Exception as e: + last_error = e + print(f"[AIClient] {provider_type} 调用失败: {e}") + continue + + # 所有 provider 都失败了 + error_msg = f"所有 AI 提供商均失败,最后错误: {last_error}" if last_error else "没有可用的 AI 配置" + print(f"[AIClient] {error_msg}") + return False, self._mock_generate(prompt, max_tokens) # 返回 mock 响应作为兜底 + + def _call_single_provider(self, ai_config: Dict, prompt: str, max_tokens: int, response_format: str) -> str: + """ + 调用单个 AI 提供商 + + Args: + ai_config: AI 配置 + prompt: 提示词 + max_tokens: 最大 token 数 + response_format: 响应格式 + + Returns: + API 响应文本 + """ + provider = ai_config.get('provider') + api_key = ai_config.get('api_key') + model = ai_config.get('model') + base_url = ai_config.get('base_url') + + if provider == 'minimax': + return self._call_minimax(api_key, model, base_url, prompt, max_tokens, response_format) + elif provider == 'zhipu': + return self._call_zhipu(api_key, model, base_url, prompt, max_tokens, response_format) + elif provider == 'alibaba': + return self._call_alibaba(api_key, model, base_url, prompt, max_tokens, response_format) + elif provider == 'openai': + return self._call_openai(api_key, model, base_url, prompt, max_tokens, response_format) + else: + raise ValueError(f"不支持的 provider: {provider}") + + def _call_minimax(self, api_key: str, model: str, base_url: str, prompt: str, max_tokens: int, response_format: str) -> str: + """调用 MiniMax API (Anthropic Messages 兼容接口)""" + url = base_url or "https://api.minimaxi.com/anthropic/v1/messages" + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + payload = { + "model": model, + "max_tokens": max_tokens, + "messages": [ + {"role": "user", "content": prompt} + ] + } + + if response_format == 'json': + payload['messages'][0]['content'] = prompt + "\n\n请直接返回 JSON 格式,不要有其他文字。" + + response = requests.post(url, headers=headers, json=payload, timeout=30) + response.raise_for_status() + data = response.json() + + # MiniMax 返回的是 Anthropic 格式,content 是一个数组 + # 需要找到 type='text' 的元素 + for item in data.get('content', []): + if item.get('type') == 'text': + return item.get('text', '') + + # 如果没有找到 text 类型,返回第一个元素的值(兼容旧格式) + return data.get('content', [{}])[0].get('text', '') + + def _call_zhipu(self, api_key: str, model: str, base_url: str, prompt: str, max_tokens: int, response_format: str) -> str: + """调用智谱 AI API (OpenAI 兼容接口)""" + # 确保 base_url 包含 /chat/completions + if base_url and not base_url.endswith('/chat/completions'): + url = f"{base_url}/chat/completions" + else: + url = base_url or "https://open.bigmodel.cn/api/paas/v4/chat/completions" + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + payload = { + "model": model, + "max_tokens": max_tokens, + "messages": [ + {"role": "user", "content": prompt} + ] + } + + response = requests.post(url, headers=headers, json=payload, timeout=30) + response.raise_for_status() + data = response.json() + return data['choices'][0]['message']['content'] + + def _call_alibaba(self, api_key: str, model: str, base_url: str, prompt: str, max_tokens: int, response_format: str) -> str: + """调用阿里云通义千问 API (OpenAI 兼容接口)""" + url = f"{base_url}/chat/completions" + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + payload = { + "model": model, + "max_tokens": max_tokens, + "messages": [ + {"role": "user", "content": prompt} + ] + } + + response = requests.post(url, headers=headers, json=payload, timeout=30) + response.raise_for_status() + data = response.json() + return data['choices'][0]['message']['content'] + + def _call_openai(self, api_key: str, model: str, base_url: str, prompt: str, max_tokens: int, response_format: str) -> str: + """调用 OpenAI API""" + url = base_url or "https://api.openai.com/v1/chat/completions" + + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + payload = { + "model": model, + "max_tokens": max_tokens, + "messages": [ + {"role": "user", "content": prompt} + ] + } + + response = requests.post(url, headers=headers, json=payload, timeout=30) + response.raise_for_status() + data = response.json() + return data['choices'][0]['message']['content'] + + def quick_generate(self, prompt: str, max_tokens: int = 100) -> str: + """快速生成(用于轻量扫描)""" + success, response = self._call_api_with_fallback(prompt, max_tokens, 'text') + return response + + def generate_json(self, prompt: str) -> Dict[str, Any]: + """生成JSON格式响应""" + success, response = self._call_api_with_fallback(prompt, 1000, 'json') + try: + return json.loads(response) + except: + return self._parse_json_from_text(response) + + def _deep_analyze(self, content_text: str, content_type: str, duration: Optional[int] = None) -> Dict[str, Any]: + """ + C5.3: 深度分析入口,根据内容时长决定是否分段分析 + + Args: + content_text: 内容文本 + content_type: 内容类型 + duration: 视频时长(秒) + + Returns: + 分析结果字典 + """ + duration = duration or 0 + + print(f"[AIClient] 深度分析: type={content_type}, duration={duration}s") + + # 长视频(>=30分钟)且是视频类型,使用分段分析 + if duration >= 1800 and content_type == "video": + print(f"[AIClient] 使用分段分析策略") + return self._analyze_in_chunks(content_text, duration) + + # 短视频直接分析 + print(f"[AIClient] 使用直接分析策略") + return self._analyze_direct(content_text) + + def _analyze_in_chunks(self, content_text: str, duration: int) -> Dict[str, Any]: + """ + C5.3: 分段分析长视频 + + Args: + content_text: SRT字幕文本 + duration: 视频时长(秒) + + Returns: + 合并后的分析结果 + """ + # 1. 获取分段策略 + strategy = get_chunking_strategy(duration) + print(f"[AIClient] 分段策略: {strategy['description']}") + + # 2. 执行分片 + chunks = chunk_content(content_text, duration) + print(f"[AIClient] 内容已分为 {len(chunks)} 段") + + # 3. 逐段分析 + chunk_summaries = [] + for i, chunk in enumerate(chunks): + print(f"[AIClient] 分析第 {i+1}/{len(chunks)} 段...") + summary = self._analyze_chunk(chunk.text, chunk.index) + + # 处理时间格式(可能是float秒数或字符串) + start_time = chunk.start_time + end_time = chunk.end_time + if isinstance(start_time, (int, float)): + start_time = self._format_time(start_time) + if isinstance(end_time, (int, float)): + end_time = self._format_time(end_time) + + chunk_summaries.append({ + 'index': chunk.index, + 'start_time': start_time, + 'end_time': end_time, + 'summary': summary + }) + + # 4. 合并所有段摘要 + print(f"[AIClient] 合并 {len(chunk_summaries)} 段分析结果") + final_result = self._merge_summaries(chunk_summaries, "video") + + # 5. 构建完整时间轴 + final_result['timeline'] = self._build_timeline_from_chunks(chunk_summaries) + + return final_result + + def _analyze_chunk(self, chunk_text: str, chunk_index: int) -> str: + """ + C5.3: 分析单个内容分片 + + Args: + chunk_text: 分片文本 + chunk_index: 分片索引 + + Returns: + 分片摘要(限制500字以内) + """ + # 截断文本避免过长 + text_sample = chunk_text[:3000] if len(chunk_text) > 3000 else chunk_text + + prompt = f"""请对以下视频片段进行简要分析(限制在300字以内): + +片段内容: +{text_sample} + +请提供: +1. 这段的核心主题(50字以内) +2. 关键要点(3-5条) +3. 重要细节或引用 + +请用中文回答,控制在300字以内。""" + + try: + summary = self.quick_generate(prompt, max_tokens=500) + # 确保不超过500字 + if len(summary) > 500: + summary = summary[:497] + "..." + return summary + except Exception as e: + print(f"[AIClient] 分片 {chunk_index} 分析失败: {e}") + return f"第{chunk_index+1}段分析失败" + + def _merge_summaries(self, chunk_summaries: List[Dict], content_type: str) -> Dict[str, Any]: + """ + C5.3: 合并所有段摘要,生成完整分析报告 + + Args: + chunk_summaries: 各段摘要列表 + content_type: 内容类型 + + Returns: + 完整分析结果 + """ + # 构建合并提示 + summaries_text = "\n\n".join([ + f"=== 第{s['index']+1}段 ({s['start_time']}-{s['end_time']}) ===\n{s['summary']}" + for s in chunk_summaries + ]) + + prompt = f"""基于以下各段分析,生成完整的视频分析报告: + +{summaries_text} + +请提供以下格式的JSON: +{{ + "summary": "整体摘要(200字以内)", + "topics": ["主题1", "主题2", "主题3", "主题4"], + "insights": ["深入见解1", "深入见解2", "深入见解3"], + "actions": ["建议行动1", "建议行动2"], + "full_analysis": "详细分析(500字以内)" +}}""" + + try: + result = self.generate_json(prompt) + + # 确保必要字段存在 + if 'topics' not in result: + result['topics'] = ["主题A", "主题B", "主题C"] + if 'insights' not in result: + result['insights'] = ["重要见解1", "重要见解2"] + if 'actions' not in result: + result['actions'] = [] + if 'summary' not in result: + result['summary'] = "长视频分析完成" + if 'full_analysis' not in result: + result['full_analysis'] = summaries_text[:500] + + return result + + except Exception as e: + print(f"[AIClient] 合并摘要失败: {e}") + # 返回基础合并结果 + return { + 'summary': f'视频共分为{len(chunk_summaries)}个部分进行分析', + 'topics': [f"主题{i+1}" for i in range(min(4, len(chunk_summaries)))], + 'insights': ['从分段分析中综合得出'], + 'actions': [], + 'full_analysis': summaries_text[:800] + } + + def _analyze_direct(self, content_text: str) -> Dict[str, Any]: + """直接分析(不分段)""" + # 截断文本 + text_sample = content_text[:5000] if len(content_text) > 5000 else content_text + + prompt = f"""请分析以下内容,提供JSON格式结果: + +{text_sample} + +返回格式: +{{ + "summary": "整体摘要(150字以内)", + "topics": ["主题1", "主题2", "主题3"], + "insights": ["见解1", "见解2"], + "actions": ["行动建议1"], + "timeline": [ + {{"start_time": "00:00", "end_time": "05:00", "title": "第一部分", "summary": "..."}} + ] +}}""" + + try: + return self.generate_json(prompt) + except Exception as e: + print(f"[AIClient] 直接分析失败: {e}") + return { + 'summary': '分析失败', + 'topics': ['无法解析'], + 'insights': ['请检查内容格式'], + 'actions': [], + 'timeline': [] + } + + def _format_time(self, seconds: float) -> str: + """格式化秒数为时间字符串""" + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + if hours > 0: + return f"{hours:02d}:{minutes:02d}:{secs:02d}" + return f"{minutes:02d}:{secs:02d}" + + def _build_timeline_from_chunks(self, chunk_summaries: List[Dict]) -> List[Dict[str, Any]]: + """从分片摘要构建时间轴""" + timeline = [] + for summary in chunk_summaries: + timeline.append({ + 'index': summary['index'], + 'start_time': summary['start_time'], + 'end_time': summary['end_time'], + 'title': f"第{summary['index']+1}部分", + 'summary': summary['summary'][:200] + "..." if len(summary['summary']) > 200 else summary['summary'] + }) + return timeline + + def _mock_generate(self, prompt: str, max_tokens: int) -> str: + """模拟AI生成(实际实现应调用真实API)""" + # 根据prompt内容返回模拟响应 + prompt_lower = prompt.lower() + + if 'json' in prompt_lower or 'json格式' in prompt: + if '主题' in prompt or 'topic' in prompt_lower: + return json.dumps({ + 'summary': '这是一个关于人工智能和技术发展的内容,涵盖了多个重要主题。', + 'topics': ['人工智能', '机器学习', '未来发展', '技术趋势'], + 'insights': [ + 'AI正在改变我们的工作方式', + '机器学习应用越来越广泛', + '未来技术发展趋势值得关注' + ], + 'actions': ['深入研究相关技术', '关注行业动态'], + 'full_analysis': '详细分析内容...' + }, ensure_ascii=False) + return json.dumps({'result': '模拟JSON响应'}) + + if '摘要' in prompt or 'summary' in prompt_lower: + return '这是一段关于人工智能发展的重要讨论,涵盖了技术趋势和未来应用。' + + if '关键词' in prompt or 'keyword' in prompt_lower: + return '人工智能, 机器学习, 深度学习, 神经网络, 自然语言处理' + + return '模拟AI响应内容' + + def _parse_json_from_text(self, text: str) -> Dict[str, Any]: + """从文本中解析JSON""" + # 尝试提取JSON块 + import re + json_match = re.search(r'\{[\s\S]*\}', text) + if json_match: + try: + return json.loads(json_match.group()) + except: + pass + return {'raw_response': text} + + +class MultiAIClient: + """ + 多AI客户端 - 支持多种AI提供商的客户端 + """ + + def __init__(self): + self.config = get_config() + self.primary_client = AIClient() + self.clients = { + 'openai': self.primary_client, + 'local': AIClient(), # 可以配置为本地模型 + } + + def _deep_analyze(self, content_text: str, content_type: str, duration: Optional[int] = None) -> Dict[str, Any]: + """使用主客户端进行深度分析""" + return self.primary_client._deep_analyze(content_text, content_type, duration) + + def quick_generate(self, prompt: str, max_tokens: int = 100, provider: str = None) -> str: + """快速生成""" + client = self.clients.get(provider, self.primary_client) + return client.quick_generate(prompt, max_tokens) diff --git a/skills/kb-workflow/src/cli.py b/skills/kb-workflow/src/cli.py new file mode 100644 index 0000000..99adb8b --- /dev/null +++ b/skills/kb-workflow/src/cli.py @@ -0,0 +1,569 @@ +#!/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 ' 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()) diff --git a/skills/kb-workflow/src/config.py b/skills/kb-workflow/src/config.py new file mode 100644 index 0000000..327e7ec --- /dev/null +++ b/skills/kb-workflow/src/config.py @@ -0,0 +1,353 @@ +""" +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() diff --git a/skills/kb-workflow/src/content_chunker.py b/skills/kb-workflow/src/content_chunker.py new file mode 100644 index 0000000..4b64d24 --- /dev/null +++ b/skills/kb-workflow/src/content_chunker.py @@ -0,0 +1,486 @@ +"""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)) diff --git a/skills/kb-workflow/src/deep_analyzer.py b/skills/kb-workflow/src/deep_analyzer.py new file mode 100644 index 0000000..5b5dcb0 --- /dev/null +++ b/skills/kb-workflow/src/deep_analyzer.py @@ -0,0 +1,438 @@ +"""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}" diff --git a/skills/kb-workflow/src/extractors/__init__.py b/skills/kb-workflow/src/extractors/__init__.py new file mode 100644 index 0000000..e00efea --- /dev/null +++ b/skills/kb-workflow/src/extractors/__init__.py @@ -0,0 +1,18 @@ +# 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' +] diff --git a/skills/kb-workflow/src/extractors/base.py b/skills/kb-workflow/src/extractors/base.py new file mode 100644 index 0000000..814c86d --- /dev/null +++ b/skills/kb-workflow/src/extractors/base.py @@ -0,0 +1,83 @@ +""" +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 diff --git a/skills/kb-workflow/src/extractors/bilibili_extractor.py b/skills/kb-workflow/src/extractors/bilibili_extractor.py new file mode 100644 index 0000000..1c822e7 --- /dev/null +++ b/skills/kb-workflow/src/extractors/bilibili_extractor.py @@ -0,0 +1,750 @@ +#!/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 + +# 导入 shortlink_resolver +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) +from shortlink_resolver 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']) +``` +""" diff --git a/skills/kb-workflow/src/extractors/easyocr_processor.py b/skills/kb-workflow/src/extractors/easyocr_processor.py new file mode 100644 index 0000000..3a1c351 --- /dev/null +++ b/skills/kb-workflow/src/extractors/easyocr_processor.py @@ -0,0 +1,355 @@ +#!/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 + } diff --git a/skills/kb-workflow/src/extractors/hard_subtitle_extractor.py b/skills/kb-workflow/src/extractors/hard_subtitle_extractor.py new file mode 100644 index 0000000..ac9d48a --- /dev/null +++ b/skills/kb-workflow/src/extractors/hard_subtitle_extractor.py @@ -0,0 +1,435 @@ +#!/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() diff --git a/skills/kb-workflow/src/extractors/ocr_processor.py b/skills/kb-workflow/src/extractors/ocr_processor.py new file mode 100644 index 0000000..6fd35ab --- /dev/null +++ b/skills/kb-workflow/src/extractors/ocr_processor.py @@ -0,0 +1,309 @@ +""" +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 + } diff --git a/skills/kb-workflow/src/extractors/subtitle_detector.py b/skills/kb-workflow/src/extractors/subtitle_detector.py new file mode 100644 index 0000000..4466c6a --- /dev/null +++ b/skills/kb-workflow/src/extractors/subtitle_detector.py @@ -0,0 +1,383 @@ +#!/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) diff --git a/skills/kb-workflow/src/extractors/video_stream_extractor.py b/skills/kb-workflow/src/extractors/video_stream_extractor.py new file mode 100644 index 0000000..dadf9c2 --- /dev/null +++ b/skills/kb-workflow/src/extractors/video_stream_extractor.py @@ -0,0 +1,517 @@ +#!/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() diff --git a/skills/kb-workflow/src/git_sync.py b/skills/kb-workflow/src/git_sync.py new file mode 100644 index 0000000..5fc9ab6 --- /dev/null +++ b/skills/kb-workflow/src/git_sync.py @@ -0,0 +1,195 @@ +"""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 diff --git a/skills/kb-workflow/src/light_scanner.py b/skills/kb-workflow/src/light_scanner.py new file mode 100644 index 0000000..ea11bca --- /dev/null +++ b/skills/kb-workflow/src/light_scanner.py @@ -0,0 +1,228 @@ +"""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) diff --git a/skills/kb-workflow/src/llm.py b/skills/kb-workflow/src/llm.py new file mode 100644 index 0000000..c7bcf54 --- /dev/null +++ b/skills/kb-workflow/src/llm.py @@ -0,0 +1,60 @@ +"""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'] diff --git a/skills/kb-workflow/src/models.py b/skills/kb-workflow/src/models.py new file mode 100644 index 0000000..5bc1709 --- /dev/null +++ b/skills/kb-workflow/src/models.py @@ -0,0 +1,213 @@ +"""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() diff --git a/skills/kb-workflow/src/outputs/__init__.py b/skills/kb-workflow/src/outputs/__init__.py new file mode 100644 index 0000000..c2331eb --- /dev/null +++ b/skills/kb-workflow/src/outputs/__init__.py @@ -0,0 +1,5 @@ +# Outputs module for obsidian-kb +from .obsidian_writer import ObsidianWriter +from .git_sync import GitSync + +__all__ = ['ObsidianWriter', 'GitSync'] diff --git a/skills/kb-workflow/src/outputs/git_sync.py b/skills/kb-workflow/src/outputs/git_sync.py new file mode 100644 index 0000000..ea5a9d2 --- /dev/null +++ b/skills/kb-workflow/src/outputs/git_sync.py @@ -0,0 +1,275 @@ +""" +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 diff --git a/skills/kb-workflow/src/outputs/obsidian_writer.py b/skills/kb-workflow/src/outputs/obsidian_writer.py new file mode 100644 index 0000000..f88d9eb --- /dev/null +++ b/skills/kb-workflow/src/outputs/obsidian_writer.py @@ -0,0 +1,385 @@ +""" +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) diff --git a/skills/kb-workflow/src/pipeline.py b/skills/kb-workflow/src/pipeline.py new file mode 100644 index 0000000..78657f9 --- /dev/null +++ b/skills/kb-workflow/src/pipeline.py @@ -0,0 +1,273 @@ +"""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}秒" diff --git a/skills/kb-workflow/src/sources/__init__.py b/skills/kb-workflow/src/sources/__init__.py new file mode 100644 index 0000000..04dc5ca --- /dev/null +++ b/skills/kb-workflow/src/sources/__init__.py @@ -0,0 +1,31 @@ +""" +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'] diff --git a/skills/kb-workflow/src/sources/base.py b/skills/kb-workflow/src/sources/base.py new file mode 100644 index 0000000..ce91fc4 --- /dev/null +++ b/skills/kb-workflow/src/sources/base.py @@ -0,0 +1,58 @@ +""" +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 diff --git a/skills/kb-workflow/src/sources/bilibili.py b/skills/kb-workflow/src/sources/bilibili.py new file mode 100644 index 0000000..29dcf3c --- /dev/null +++ b/skills/kb-workflow/src/sources/bilibili.py @@ -0,0 +1,466 @@ +""" +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}" diff --git a/skills/kb-workflow/src/sources/local.py b/skills/kb-workflow/src/sources/local.py new file mode 100644 index 0000000..a522812 --- /dev/null +++ b/skills/kb-workflow/src/sources/local.py @@ -0,0 +1,57 @@ +"""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 diff --git a/skills/kb-workflow/src/sources/markdown_converter.py b/skills/kb-workflow/src/sources/markdown_converter.py new file mode 100644 index 0000000..c0f912d --- /dev/null +++ b/skills/kb-workflow/src/sources/markdown_converter.py @@ -0,0 +1,17 @@ +from markdownify import MarkdownConverter as _MarkdownConverter + +class MarkdownConverter: + """HTML 转 Markdown 转换器,保留完整格式""" + + def __init__(self): + self.converter = _MarkdownConverter( + heading_style="ATX", + bullets="*,-", + code_blocks=True, + tables=True, + strip=["script", "style", "nav", "footer", "aside"] + ) + + def convert(self, html: str) -> str: + """转换 HTML 为 Markdown""" + return self.converter.convert(html) diff --git a/skills/kb-workflow/src/sources/youtube.py b/skills/kb-workflow/src/sources/youtube.py new file mode 100644 index 0000000..efa1137 --- /dev/null +++ b/skills/kb-workflow/src/sources/youtube.py @@ -0,0 +1,53 @@ +"""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 diff --git a/skills/kb-workflow/src/sources/zhihu.py b/skills/kb-workflow/src/sources/zhihu.py new file mode 100644 index 0000000..f8a59f4 --- /dev/null +++ b/skills/kb-workflow/src/sources/zhihu.py @@ -0,0 +1,91 @@ +"""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 diff --git a/skills/kb-workflow/src/sources/zhihu_drission_extractor.py b/skills/kb-workflow/src/sources/zhihu_drission_extractor.py new file mode 100644 index 0000000..419a742 --- /dev/null +++ b/skills/kb-workflow/src/sources/zhihu_drission_extractor.py @@ -0,0 +1,514 @@ +""" +知乎 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) diff --git a/skills/kb-workflow/src/sources/zhihu_playwright_extractor.py b/skills/kb-workflow/src/sources/zhihu_playwright_extractor.py new file mode 100644 index 0000000..2baf648 --- /dev/null +++ b/skills/kb-workflow/src/sources/zhihu_playwright_extractor.py @@ -0,0 +1,385 @@ +""" +知乎 Playwright 内容提取器 +使用 Playwright 浏览器自动化工具抓取知乎内容 +""" + +import re +import sys +import os +from typing import Dict, Optional, Any +from urllib.parse import urlparse + +# 添加当前目录到 Python 路径以支持绝对导入 +current_dir = os.path.dirname(os.path.abspath(__file__)) +if current_dir not in sys.path: + sys.path.insert(0, current_dir) + +try: + from .markdown_converter import MarkdownConverter +except ImportError: + from markdown_converter import MarkdownConverter + + +class PlaywrightBrowser: + """Playwright 浏览器管理器""" + + def __init__(self, headless: bool = True, timeout: int = 30): + self.headless = headless + self.timeout = timeout * 1000 + self.browser = None + self.context = None + self.page = None + + def start(self): + """启动浏览器""" + from playwright.sync_api import sync_playwright + self.playwright = sync_playwright().start() + self.browser = self.playwright.chromium.launch(headless=self.headless) + self.context = self.browser.new_context( + user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36' + ) + self.page = self.context.new_page() + self.page.set_default_timeout(self.timeout) + + def close(self): + """关闭浏览器""" + if self.page: + self.page.close() + if self.context: + self.context.close() + if self.browser: + self.browser.close() + if hasattr(self, 'playwright'): + self.playwright.stop() + + def goto(self, url: str, wait_until: str = "networkidle"): + """导航到指定URL""" + return self.page.goto(url, wait_until=wait_until) + + def wait_for_selector(self, selector: str, timeout: int = None): + """等待选择器出现""" + timeout_ms = timeout * 1000 if timeout else self.timeout + return self.page.wait_for_selector(selector, timeout=timeout_ms) + + def query_selector(self, selector: str): + """查询选择器""" + return self.page.query_selector(selector) + + def query_selector_all(self, selector: str): + """查询所有匹配的选择器""" + return self.page.query_selector_all(selector) + + def evaluate(self, script: str): + """执行 JavaScript 代码""" + return self.page.evaluate(script) + + +class ZhihuPlaywrightExtractor: + """知乎内容提取器 - 使用 Playwright""" + + URL_PATTERNS = { + 'answer': r'zhihu\.com/question/\d+/answer/(\d+)', + 'article': r'zhuanlan\.zhihu\.com/p/(\d+)', + 'question': r'zhihu\.com/question/(\d+)(?:$|/)', + } + + def __init__(self, headless: bool = True, timeout: int = 30): + """ + 初始化提取器 + + Args: + headless: 是否使用无头浏览器 + timeout: 超时时间(秒) + """ + self.browser = PlaywrightBrowser(headless=headless, timeout=timeout) + self.markdown_converter = MarkdownConverter() + + def identify_url_type(self, url: str) -> Optional[str]: + """ + 识别 URL 类型 + + Returns: + 'answer', 'article', 'question' 或 None + """ + for url_type, pattern in self.URL_PATTERNS.items(): + if re.search(pattern, url): + return url_type + return None + + def extract_id(self, url: str) -> Optional[str]: + """ + 从 URL 中提取内容 ID + + Returns: + 内容 ID 或 None + """ + url_type = self.identify_url_type(url) + if url_type: + pattern = self.URL_PATTERNS[url_type] + match = re.search(pattern, url) + if match: + return match.group(1) + return None + + def _extract_text_content(self, element) -> str: + """提取元素的文本内容""" + if element is None: + return '' + return element.inner_text() if hasattr(element, 'inner_text') else '' + + def _extract_html_content(self, element) -> str: + """提取元素的 HTML 内容""" + if element is None: + return '' + return element.inner_html() if hasattr(element, 'inner_html') else '' + + def _extract_answer_content(self) -> Dict[str, Any]: + """提取回答内容""" + result = { + 'type': 'answer', + 'title': '', + 'author': '', + 'content': '', + 'content_markdown': '', + 'publish_time': '', + 'vote_up': 0, + } + + # 打印页面标题用于调试 + page_title = self.browser.page.title() + print(f"页面标题: {page_title}") + + # 尝试保存页面快照用于调试 + try: + import os + debug_dir = '/home/admin/.openclaw/workspace/debug' + os.makedirs(debug_dir, exist_ok=True) + self.browser.page.screenshot(path=os.path.join(debug_dir, 'zhihu_screenshot.png')) + with open(os.path.join(debug_dir, 'zhihu_page.html'), 'w', encoding='utf-8') as f: + f.write(self.browser.page.content()) + print(f"页面快照已保存到 {debug_dir}") + except Exception as e: + print(f"保存页面快照时出错: {e}") + + # 尝试多个可能的选择器 + selectors = { + 'title': ['h1.QuestionHeader-title', 'h1', '.QuestionHeader-main'], + 'author': ['.AuthorInfo-name', '.AuthorInfo .Text', '[itemprop="name"]'], + 'content': ['.RichContent-inner', '.RichContent', '.List-itemText', '[data-zop-feedlist] article'], + 'time': ['.ContentItem-time', '[itemprop="dateCreated"]'], + 'vote': ['.VoteButton--up .VoteButton-label', '.VoteButton--up'] + } + + for selector in selectors['title']: + title_elem = self.browser.query_selector(selector) + if title_elem: + result['title'] = self._extract_text_content(title_elem) + print(f"找到标题(使用选择器 {selector}): {result['title'][:50]}...") + break + + for selector in selectors['author']: + author_elem = self.browser.query_selector(selector) + if author_elem: + result['author'] = self._extract_text_content(author_elem) + print(f"找到作者(使用选择器 {selector}): {result['author']}") + break + + for selector in selectors['content']: + content_elem = self.browser.query_selector(selector) + if content_elem: + html_content = self._extract_html_content(content_elem) + result['content'] = html_content + result['content_markdown'] = self.markdown_converter.convert(html_content) + print(f"找到内容(使用选择器 {selector}),长度: {len(result['content_markdown'])}") + break + + for selector in selectors['time']: + time_elem = self.browser.query_selector(selector) + if time_elem: + result['publish_time'] = time_elem.get_attribute('data-tooltip') or self._extract_text_content(time_elem) + break + + for selector in selectors['vote']: + vote_elem = self.browser.query_selector(selector) + if vote_elem: + vote_text = self._extract_text_content(vote_elem) + try: + result['vote_up'] = int(vote_text.replace(',', '')) + except (ValueError, AttributeError): + pass + break + + return result + + def _extract_article_content(self) -> Dict[str, Any]: + """提取文章内容""" + result = { + 'type': 'article', + 'title': '', + 'author': '', + 'content': '', + 'content_markdown': '', + 'publish_time': '', + 'vote_up': 0, + } + + # 提取文章标题 + title_elem = self.browser.query_selector('.Post-Title') + if title_elem: + result['title'] = self._extract_text_content(title_elem) + + # 提取作者信息 + author_elem = self.browser.query_selector('.AuthorInfo-name') + if author_elem: + result['author'] = self._extract_text_content(author_elem) + + # 提取文章内容 + content_elem = self.browser.query_selector('.Post-RichText') + if content_elem: + html_content = self._extract_html_content(content_elem) + result['content'] = html_content + result['content_markdown'] = self.markdown_converter.convert(html_content) + + # 提取发布时间 + time_elem = self.browser.query_selector('.ContentItem-time') + if time_elem: + result['publish_time'] = time_elem.get_attribute('data-tooltip') or self._extract_text_content(time_elem) + + # 提取点赞数 + vote_elem = self.browser.query_selector('.VoteButton--up .VoteButton-label') + if vote_elem: + vote_text = self._extract_text_content(vote_elem) + try: + result['vote_up'] = int(vote_text.replace(',', '')) + except (ValueError, AttributeError): + pass + + return result + + def _extract_question_content(self) -> Dict[str, Any]: + """提取问题内容(问题页本身)""" + result = { + 'type': 'question', + 'title': '', + 'author': '', + 'content': '', + 'content_markdown': '', + 'publish_time': '', + 'vote_up': 0, + } + + # 提取问题标题 + title_elem = self.browser.query_selector('h1.QuestionHeader-title') + if title_elem: + result['title'] = self._extract_text_content(title_elem) + + # 提取问题详情 + detail_elem = self.browser.query_selector('.QuestionHeader-detail') + if detail_elem: + html_content = self._extract_html_content(detail_elem) + result['content'] = html_content + result['content_markdown'] = self.markdown_converter.convert(html_content) + + # 问题的作者通常是提问者 + author_elem = self.browser.query_selector('.AuthorInfo-name') + if author_elem: + result['author'] = self._extract_text_content(author_elem) + + return result + + def extract(self, url: str) -> Optional[Dict[str, Any]]: + """ + 从 URL 提取知乎内容 + + Args: + url: 知乎 URL + + Returns: + 包含提取内容的字典,结构如下: + { + 'type': 'answer' | 'article' | 'question', + 'title': str, + 'author': str, + 'content': str (HTML), + 'content_markdown': str (Markdown), + 'publish_time': str, + 'vote_up': int, + } + 如果提取失败返回 None + """ + try: + # 识别 URL 类型 + url_type = self.identify_url_type(url) + if not url_type: + print(f"无法识别 URL 类型: {url}") + return None + + print(f"URL 类型: {url_type}") + print(f"启动浏览器...") + + # 启动浏览器 + self.browser.start() + + print(f"访问页面: {url}") + # 访问页面 + self.browser.goto(url, wait_until="domcontentloaded") + + print("等待页面加载...") + import time + time.sleep(3) # 等待 JavaScript 执行 + + # 尝试多个可能的选择器 + if url_type == 'answer': + selectors = ['.RichContent-inner', '.RichContent', '.List-itemText', '[data-zop-feedlist]'] + print(f"尝试查找选择器: {selectors}") + result = self._extract_answer_content() + elif url_type == 'article': + selectors = ['.Post-RichText', '.Post-Title'] + print(f"尝试查找选择器: {selectors}") + result = self._extract_article_content() + else: # question + selectors = ['.QuestionHeader-title', 'h1'] + print(f"尝试查找选择器: {selectors}") + result = self._extract_question_content() + + # 添加额外信息 + result['url'] = url + result['content_id'] = self.extract_id(url) + + print(f"提取成功! 标题: {result['title'][:50]}...") + return result + + except Exception as e: + print(f"提取知乎内容时出错: {e}") + import traceback + traceback.print_exc() + return None + finally: + # 关闭浏览器 + self.browser.close() + + def __enter__(self): + """上下文管理器入口""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """上下文管理器出口""" + self.browser.close() + return False + + +# 便捷函数 +def extract_zhihu_content(url: str, headless: bool = True, timeout: int = 30) -> Optional[Dict[str, Any]]: + """ + 便捷函数:提取知乎内容 + + Args: + url: 知乎 URL + headless: 是否使用无头浏览器 + timeout: 超时时间(秒) + + Returns: + 提取的内容字典 + """ + with ZhihuPlaywrightExtractor(headless=headless, timeout=timeout) as extractor: + return extractor.extract(url) diff --git a/skills/kb-workflow/src/storage.py b/skills/kb-workflow/src/storage.py new file mode 100644 index 0000000..4ec146b --- /dev/null +++ b/skills/kb-workflow/src/storage.py @@ -0,0 +1,168 @@ +"""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 diff --git a/skills/kb-workflow/src/template.py b/skills/kb-workflow/src/template.py new file mode 100644 index 0000000..2c71873 --- /dev/null +++ b/skills/kb-workflow/src/template.py @@ -0,0 +1,71 @@ +"""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 + + +""" diff --git a/skills/kb-workflow/src/template_renderer.py b/skills/kb-workflow/src/template_renderer.py new file mode 100644 index 0000000..5661b78 --- /dev/null +++ b/skills/kb-workflow/src/template_renderer.py @@ -0,0 +1,307 @@ +""" +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) diff --git a/skills/kb-workflow/src/templates/article.md b/skills/kb-workflow/src/templates/article.md new file mode 100644 index 0000000..157c274 --- /dev/null +++ b/skills/kb-workflow/src/templates/article.md @@ -0,0 +1,33 @@ +--- +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 %} diff --git a/skills/kb-workflow/src/templates/light_video.md b/skills/kb-workflow/src/templates/light_video.md new file mode 100644 index 0000000..59b3148 --- /dev/null +++ b/skills/kb-workflow/src/templates/light_video.md @@ -0,0 +1,20 @@ +--- +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 %} diff --git a/skills/kb-workflow/src/templates/topic_index.md b/skills/kb-workflow/src/templates/topic_index.md new file mode 100644 index 0000000..78c5303 --- /dev/null +++ b/skills/kb-workflow/src/templates/topic_index.md @@ -0,0 +1,16 @@ +--- +title: {{topic_name}} +date: {{date}} +tags: [topic-index] +--- + +# {{topic_name}} + +## 简介 +{{description}} + +## 相关内容 +{{content_list}} + +## 学习路径 +{{learning_path}} diff --git a/skills/kb-workflow/src/templates/tutorial_video.md b/skills/kb-workflow/src/templates/tutorial_video.md new file mode 100644 index 0000000..7f589df --- /dev/null +++ b/skills/kb-workflow/src/templates/tutorial_video.md @@ -0,0 +1,35 @@ +--- +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 %}