#!/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 from src.sources.bilibili import BilibiliAdapter 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_add(args: argparse.Namespace): """Handle add command - add a single video to inbox.""" print_header("Add Video to Inbox") if not CONFIG_AVAILABLE: print("Error: Required modules not available. Please check installation.") return 1 # Import here to avoid issues if not available from src.models import ContentStatus url = args.url # Handle short links (b23.tv) if 'b23.tv' in url: print(f" ⚠️ 检测到短链接: {url}") print(f" 💡 B站对短链接有反爬虫保护,请提供:") print(f" 1. 完整URL: https://www.bilibili.com/video/BVxxxxx") print(f" 2. 或BV号: BVxxxxx") print(f"\n 提示: 可以在浏览器打开短链接,从地址栏复制完整URL") return 1 # Create adapter adapter = BilibiliAdapter() # Extract video metadata print(f" → 正在获取视频信息: {url}") content = adapter.extract_content(url) if not content: print(" ❌ 无法提取视频信息") return 1 print(f" → 标题: {content.title}") print(f" → UP主: {content.author}") print(f" → 时长: {content.duration}秒") # Get transcript print(" → 正在获取字幕...") transcript = adapter.get_transcript(content) if transcript: content.transcript = transcript print(f" ✓ 字幕已获取 ({len(transcript)} 字符)") else: print(" ⚠️ 无法获取字幕") # Set status to CONFIRMED (ready to process) content.status = ContentStatus.CONFIRMED # Confirm before saving (unless --yes) if not args.yes: print(f"\n📋 准备添加到收件箱:") print(f" 标题: {content.title}") print(f" UP主: {content.author}") print(f" 时长: {content.duration}秒") print(f" 字幕: {'有' if transcript else '无'}") if args.process: print(f" 操作: 添加并立即处理") else: print(f" 操作: 仅添加到收件箱") confirm = input("\n确认添加? [Y/n]: ").strip().lower() if confirm and confirm not in ('y', 'yes'): print("❌ 已取消") return 0 # Save to inbox print("\n → 保存到收件箱...") storage = Storage() storage.save_content(content) print(f"\n✅ 视频已添加到收件箱!") print(f" Content ID: {content.id}") # Process immediately if --process flag is set if args.process: print(f"\n🔄 开始处理内容...") try: from src.pipeline import ProcessingPipeline from src.models import ContentStatus pipeline = ProcessingPipeline() content_text = content.transcript or content.summary or "" result = pipeline.process_full(content, content_text) storage.save_content(result) print(f" ✅ 处理完成") print(f" 📄 笔记: {result.output_path or 'N/A'}") print(f" 🏷️ 标签: {', '.join(result.tags)}") except Exception as e: print(f" ❌ 处理失败: {e}") import traceback traceback.print_exc() return 1 else: print(f"\n💡 使用以下命令处理:") print(f" python3 src/cli.py process {content.id}") print(f" 或:") print(f" python3 src/cli.py process all") 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 add "BV1xx411c7mD" %(prog)s add "https://www.bilibili.com/video/BV1xx411c7mD" --process %(prog)s add "https://b23.tv/xxxxx" --yes %(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') # add 子命令 add_parser = subparsers.add_parser( 'add', aliases=['a'], help='Add a single video to inbox' ) add_parser.add_argument( 'url', help='Video URL or BV ID (supports: BVxxxxx, full URL)' ) add_parser.add_argument( '--process', action='store_true', help='Process immediately after adding' ) add_parser.add_argument( '--yes', action='store_true', help='Skip confirmation prompts' ) add_parser.set_defaults(func=cmd_add) # 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())