a69225ff2b
- 使用 DrissionPage 替代 Playwright - 实现 Cookie 管理和反爬绕过 - 支持回答/文章/问题三种内容类型 - 灵活配置(文件/环境变量/配置文件) - 更新文档和配置文件
354 lines
11 KiB
Python
354 lines
11 KiB
Python
"""
|
|
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()
|