ee18cde745
- Add cmd_add() function to support adding single videos to inbox - Support BV IDs, full URLs, and short links (with friendly warning) - Add --process flag to immediately process after adding - Add --yes flag to skip confirmation prompts - Update SKILL.md with /kb-add command documentation
700 lines
21 KiB
Python
700 lines
21 KiB
Python
#!/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 <id>' to process an item, or 'process all' to process all")
|
|
|
|
return 0
|
|
|
|
|
|
def cmd_process(args: argparse.Namespace):
|
|
"""Handle process command - process content items."""
|
|
print_header("Process Content")
|
|
|
|
if not CONFIG_AVAILABLE:
|
|
print("Error: Pipeline module not available.")
|
|
return 1
|
|
|
|
storage = Storage()
|
|
|
|
# Import pipeline components
|
|
try:
|
|
from src.pipeline import ProcessingPipeline
|
|
from src.light_scanner import LightScanner
|
|
from src.deep_analyzer import DeepAnalyzer
|
|
from src.config import get_config
|
|
|
|
config = get_config()
|
|
pipeline = ProcessingPipeline()
|
|
except Exception as e:
|
|
print(f"❌ Error initializing pipeline: {e}")
|
|
return 1
|
|
|
|
# Determine what to process
|
|
if args.content_id == 'all':
|
|
items = storage.list_inbox(status=ContentStatus.CONFIRMED)
|
|
if not items:
|
|
print("📭 Inbox is empty! Nothing to process.")
|
|
return 0
|
|
|
|
print(f"Found {len(items)} item(s) to process.\n")
|
|
|
|
if not args.yes:
|
|
confirm = input("Process all items? [Y/n]: ").strip().lower()
|
|
if confirm and confirm not in ('y', 'yes'):
|
|
print("❌ Cancelled.")
|
|
return 0
|
|
else:
|
|
item = storage.load_content(args.content_id)
|
|
if not item:
|
|
print(f"❌ Content not found: {args.content_id}")
|
|
return 1
|
|
items = [item]
|
|
print(f"Processing single item: {item.title}\n")
|
|
|
|
# Process items with progress
|
|
success_count = 0
|
|
fail_count = 0
|
|
|
|
for i, item in enumerate(items, 1):
|
|
content_id = item.id
|
|
title = item.title or 'Untitled'
|
|
|
|
print(f"[{i}/{len(items)}] Processing: {title[:50]}...")
|
|
print(f" ID: {content_id}")
|
|
|
|
try:
|
|
# Use the pipeline to process the content
|
|
# The pipeline expects content_text - we'll use transcript or summary
|
|
content_text = item.transcript or item.summary or ""
|
|
|
|
result = pipeline.process_full(item, content_text)
|
|
|
|
# Save the updated content
|
|
storage.save_content(result)
|
|
|
|
print(f" ✅ Success")
|
|
print(f" 📄 Note: {result.output_path or 'N/A'}")
|
|
print(f" 🏷️ Tags: {', '.join(result.tags)}")
|
|
success_count += 1
|
|
|
|
except Exception as e:
|
|
print(f" ❌ Error: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
fail_count += 1
|
|
|
|
print()
|
|
|
|
# Summary
|
|
print(f"\n📊 Processing Complete")
|
|
print(f" Successful: {success_count}")
|
|
print(f" Failed: {fail_count}")
|
|
print(f" Total: {len(items)}")
|
|
|
|
return 0 if fail_count == 0 else 1
|
|
|
|
|
|
def cmd_skip(args: argparse.Namespace):
|
|
"""Handle skip command - skip/remove an item from inbox."""
|
|
print_header("Skip Content")
|
|
|
|
if not CONFIG_AVAILABLE:
|
|
print("Error: Storage module not available.")
|
|
return 1
|
|
|
|
storage = Storage()
|
|
|
|
# Find the item
|
|
content = storage.load_content(args.content_id)
|
|
if not content:
|
|
print(f"❌ Content not found: {args.content_id}")
|
|
return 1
|
|
|
|
print(f"Skipping: {content.title or 'Untitled'}")
|
|
print(f"ID: {args.content_id}")
|
|
|
|
if args.reason:
|
|
print(f"Reason: {args.reason}")
|
|
|
|
# Mark as skipped
|
|
content.status = ContentStatus.SKIPPED
|
|
if args.reason:
|
|
content.summary = f"[Skipped: {args.reason}] {content.summary or ''}"
|
|
|
|
storage.save_content(content)
|
|
|
|
print("✅ Item skipped and marked as skipped.")
|
|
if args.reason:
|
|
print(f" Reason recorded: {args.reason}")
|
|
|
|
return 0
|
|
|
|
|
|
def cmd_status(args: argparse.Namespace):
|
|
"""Handle status command - show system status."""
|
|
print_header("System Status")
|
|
|
|
# Git status
|
|
print("📁 Git Repository Status")
|
|
print("-" * 30)
|
|
|
|
import subprocess
|
|
try:
|
|
# Check if we're in a git repo
|
|
result = subprocess.run(
|
|
['git', 'status', '--short'],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=os.getcwd()
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
if result.stdout.strip():
|
|
print("Uncommitted changes:")
|
|
print(result.stdout)
|
|
else:
|
|
print("✅ Working directory clean")
|
|
|
|
# Get branch info
|
|
branch_result = subprocess.run(
|
|
['git', 'branch', '--show-current'],
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
if branch_result.returncode == 0:
|
|
print(f"Branch: {branch_result.stdout.strip()}")
|
|
else:
|
|
print("⚠️ Not a git repository or git not available")
|
|
except FileNotFoundError:
|
|
print("⚠️ Git not available")
|
|
except Exception as e:
|
|
print(f"⚠️ Git error: {e}")
|
|
|
|
print()
|
|
|
|
# Inbox statistics
|
|
print("📥 Inbox Statistics")
|
|
print("-" * 30)
|
|
|
|
if CONFIG_AVAILABLE:
|
|
storage = Storage()
|
|
stats = storage.get_stats()
|
|
|
|
print(f"Pending (confirmed): {stats.get(ContentStatus.CONFIRMED.value, 0)}")
|
|
print(f"New: {stats.get(ContentStatus.NEW.value, 0)}")
|
|
print(f"Scanned: {stats.get(ContentStatus.SCANNED.value, 0)}")
|
|
print(f"Analyzed: {stats.get(ContentStatus.ANALYZED.value, 0)}")
|
|
print(f"Generated: {stats.get(ContentStatus.GENERATED.value, 0)}")
|
|
print(f"Skipped: {stats.get(ContentStatus.SKIPPED.value, 0)}")
|
|
else:
|
|
print("⚠️ Storage module not available")
|
|
|
|
print()
|
|
|
|
# Configuration summary
|
|
print("⚙️ Configuration")
|
|
print("-" * 30)
|
|
|
|
if CONFIG_AVAILABLE:
|
|
config = Config()
|
|
subscriptions = config.get('subscriptions', [])
|
|
print(f"Active subscriptions: {len(subscriptions)}")
|
|
|
|
if subscriptions:
|
|
for sub in subscriptions:
|
|
print(f" • {sub.get('name', 'Unnamed')} ({sub.get('platform')})")
|
|
else:
|
|
print("⚠️ Config module not available")
|
|
|
|
return 0
|
|
|
|
|
|
def cmd_search(args: argparse.Namespace):
|
|
"""Handle search command - search processed notes."""
|
|
print_header(f"Search: '{args.query}'")
|
|
|
|
if not CONFIG_AVAILABLE:
|
|
print("Error: Storage module not available.")
|
|
return 1
|
|
|
|
storage = Storage()
|
|
|
|
# Perform search across all content
|
|
results = storage.search(args.query)
|
|
|
|
if not results:
|
|
print("🔍 No results found.")
|
|
return 0
|
|
|
|
# Filter by tags if requested
|
|
if args.in_tags:
|
|
query_lower = args.query.lower()
|
|
results = [r for r in results if any(query_lower in tag.lower() for tag in r.tags)]
|
|
|
|
if not results:
|
|
print("🔍 No results found in tags.")
|
|
return 0
|
|
|
|
print(f"Found {len(results)} result(s):\n")
|
|
|
|
# Format results
|
|
for i, content in enumerate(results, 1):
|
|
title = content.title or 'Untitled'
|
|
path = content.output_path or f"inbox/{content.id}.json"
|
|
tags = content.tags
|
|
|
|
print(f"{i}. {title}")
|
|
print(f" ID: {content.id}")
|
|
print(f" Status: {content.status.value}")
|
|
print(f" Path: {path}")
|
|
if tags:
|
|
print(f" Tags: {', '.join(tags)}")
|
|
|
|
# Show matching content preview
|
|
if not args.in_tags and content.summary:
|
|
content_text = content.summary
|
|
query_lower = args.query.lower()
|
|
idx = content_text.lower().find(query_lower)
|
|
if idx >= 0:
|
|
start = max(0, idx - 30)
|
|
end = min(len(content_text), idx + len(args.query) + 30)
|
|
preview = content_text[start:end]
|
|
if start > 0:
|
|
preview = "..." + preview
|
|
if end < len(content_text):
|
|
preview = preview + "..."
|
|
print(f" Match: {preview}")
|
|
|
|
print()
|
|
|
|
return 0
|
|
|
|
|
|
def main():
|
|
"""Main entry point for CLI."""
|
|
parser = argparse.ArgumentParser(
|
|
description='Obsidian KB 知识库维护工具',
|
|
prog='obsidian-kb',
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
%(prog)s 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())
|