cleanup: 删除kb-workflow遗留代码
- 删除 kb_handler.py,不在SKILL.md要求中 - 删除 kb_pipeline.py,不在SKILL.md要求中 - 修改 bilibili_extractor.py 使用 kb-link-parser 解析短链 - 添加 SkillBug.md 记录问题 Skills should follow their SKILL.md specifications only.
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# SkillBug.md - 技能问题记录
|
||||||
|
|
||||||
|
## 2026-02-10 - kb-workflow 技能依赖缺失 ✅ 已解决
|
||||||
|
|
||||||
|
**技能:** kb-workflow
|
||||||
|
**问题:** 代码中引用了不存在的模块
|
||||||
|
|
||||||
|
### 解决方案
|
||||||
|
1. **删除 kb_handler.py** - 不在 SKILL.md 要求中,是遗留代码
|
||||||
|
2. **删除 obsidian-kb** - 不再需要,恢复它是错误操作
|
||||||
|
3. **kb-workflow 应该使用 kb-link-parser** - 按 SKILL.md 规定走标准流程
|
||||||
|
|
||||||
|
### 当前状态
|
||||||
|
- ✅ kb_workflow.py 正常
|
||||||
|
- ✅ 使用 kb-link-parser 解析短链
|
||||||
|
|
||||||
|
### kb-workflow 正确用法
|
||||||
|
```bash
|
||||||
|
# 查看状态
|
||||||
|
python kb_workflow.py --trigger status
|
||||||
|
|
||||||
|
# 处理待处理内容
|
||||||
|
python kb_workflow.py --trigger manual --scope pending --limit 5
|
||||||
|
```
|
||||||
@@ -1,551 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Auto-trigger handler for obsidian-kb skill.
|
|
||||||
|
|
||||||
Detects video/article links in user messages and automatically processes them.
|
|
||||||
Supports Bilibili, YouTube, and Zhihu.
|
|
||||||
|
|
||||||
## 触发词
|
|
||||||
- "保存到知识库" + 链接 → 添加到收件箱并处理
|
|
||||||
- "订阅到知识库" + 链接 → 添加订阅
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
from typing import List, Dict, Optional, Tuple
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
# Add path for imports
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'src'))
|
|
||||||
|
|
||||||
# Import short link resolver
|
|
||||||
from shortlink_resolver import (
|
|
||||||
ShortLinkResolver,
|
|
||||||
detect_and_format,
|
|
||||||
PlaywrightResolver
|
|
||||||
)
|
|
||||||
|
|
||||||
# 导入主流程
|
|
||||||
try:
|
|
||||||
from main_pipeline import MainPipeline
|
|
||||||
MAIN_PIPELINE_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
MAIN_PIPELINE_AVAILABLE = False
|
|
||||||
|
|
||||||
# Pattern definitions for auto-detection
|
|
||||||
PATTERNS = {
|
|
||||||
'bilibili_full': re.compile(r'https?://(?:www\.)?bilibili\.com/video/(BV\w+)'),
|
|
||||||
'bilibili_short': re.compile(r'https?://b23\.tv/(\w+)'),
|
|
||||||
'bilibili_bv': re.compile(r'\b(BV[a-zA-Z0-9]{10})\b'),
|
|
||||||
'youtube_full': re.compile(r'https?://(?:www\.)?youtube\.com/watch\?v=([a-zA-Z0-9_-]{11})'),
|
|
||||||
'youtube_short': re.compile(r'https?://youtu\.be/([a-zA-Z0-9_-]{11})'),
|
|
||||||
'zhihu_article': re.compile(r'https?://zhuanlan\.zhihu\.com/p/(\d+)'),
|
|
||||||
'zhihu_answer': re.compile(r'https?://(?:www\.)?zhihu\.com/question/\d+/answer/(\d+)'),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 触发词模式
|
|
||||||
TRIGGER_PATTERNS = {
|
|
||||||
'save': re.compile(r'(保存到知识库|加入知识库|添加到知识库)', re.IGNORECASE),
|
|
||||||
'subscribe': re.compile(r'(订阅到知识库|订阅知识库|关注到知识库)', re.IGNORECASE),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def detect_links(text: str) -> List[Dict[str, str]]:
|
|
||||||
"""
|
|
||||||
Detect video/article links in text.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: Input text to search
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of detected links with metadata:
|
|
||||||
[
|
|
||||||
{
|
|
||||||
'url': 'https://...',
|
|
||||||
'platform': 'bilibili',
|
|
||||||
'type': 'video',
|
|
||||||
'id': 'BVxxx',
|
|
||||||
'is_short': False
|
|
||||||
},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
"""
|
|
||||||
detected = []
|
|
||||||
seen_ids = set() # Track IDs to avoid duplicates
|
|
||||||
|
|
||||||
for pattern_name, pattern in PATTERNS.items():
|
|
||||||
matches = pattern.finditer(text)
|
|
||||||
|
|
||||||
for match in matches:
|
|
||||||
# Determine platform and type
|
|
||||||
if 'bilibili' in pattern_name:
|
|
||||||
platform = 'bilibili'
|
|
||||||
content_type = 'video'
|
|
||||||
if 'short' in pattern_name:
|
|
||||||
is_short = True
|
|
||||||
url = match.group(0)
|
|
||||||
content_id = None # Need to resolve short URL
|
|
||||||
else:
|
|
||||||
is_short = False
|
|
||||||
if pattern_name == 'bilibili_bv':
|
|
||||||
url = f"https://www.bilibili.com/video/{match.group(1)}"
|
|
||||||
else:
|
|
||||||
url = match.group(0)
|
|
||||||
content_id = match.group(1)
|
|
||||||
|
|
||||||
elif 'youtube' in pattern_name:
|
|
||||||
platform = 'youtube'
|
|
||||||
content_type = 'video'
|
|
||||||
is_short = 'short' in pattern_name
|
|
||||||
url = match.group(0)
|
|
||||||
content_id = match.group(1)
|
|
||||||
|
|
||||||
elif 'zhihu' in pattern_name:
|
|
||||||
platform = 'zhihu'
|
|
||||||
content_type = 'article'
|
|
||||||
is_short = False
|
|
||||||
url = match.group(0)
|
|
||||||
content_id = match.group(1)
|
|
||||||
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip duplicates by URL or ID
|
|
||||||
unique_key = url if (not content_id or is_short) else content_id
|
|
||||||
if unique_key in seen_ids:
|
|
||||||
continue
|
|
||||||
seen_ids.add(unique_key)
|
|
||||||
|
|
||||||
detected.append({
|
|
||||||
'url': url,
|
|
||||||
'platform': platform,
|
|
||||||
'type': content_type,
|
|
||||||
'id': content_id,
|
|
||||||
'is_short': is_short,
|
|
||||||
'pattern': pattern_name
|
|
||||||
})
|
|
||||||
|
|
||||||
return detected
|
|
||||||
|
|
||||||
|
|
||||||
def format_preview(detected: List[Dict[str, str]]) -> str:
|
|
||||||
"""
|
|
||||||
Format a preview message for detected links.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
detected: List of detected links
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted preview message
|
|
||||||
"""
|
|
||||||
if not detected:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
lines = ["🔍 检测到内容链接:\n"]
|
|
||||||
|
|
||||||
for i, item in enumerate(detected, 1):
|
|
||||||
platform_emoji = {
|
|
||||||
'bilibili': '📺',
|
|
||||||
'youtube': '▶️',
|
|
||||||
'zhihu': '📝'
|
|
||||||
}.get(item['platform'], '🔗')
|
|
||||||
|
|
||||||
platform_name = {
|
|
||||||
'bilibili': 'B站视频',
|
|
||||||
'youtube': 'YouTube视频',
|
|
||||||
'zhihu': '知乎文章'
|
|
||||||
}.get(item['platform'], item['platform'])
|
|
||||||
|
|
||||||
lines.append(f"{platform_emoji} [{i}] {platform_name}")
|
|
||||||
lines.append(f" URL: {item['url']}")
|
|
||||||
|
|
||||||
if item['is_short']:
|
|
||||||
lines.append(f" ⚠️ 短链接,需要用户提供完整URL或ID")
|
|
||||||
elif item['id']:
|
|
||||||
lines.append(f" ID: {item['id']}")
|
|
||||||
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def format_short_link_help(short_link: Optional[str] = None) -> str:
|
|
||||||
"""
|
|
||||||
Format help message for short links.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
short_link: Optional short link URL to mention
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Help message
|
|
||||||
"""
|
|
||||||
return ShortLinkResolver.format_short_link_help(short_link)
|
|
||||||
|
|
||||||
|
|
||||||
def get_action_prompt(detected: List[Dict[str, str]]) -> str:
|
|
||||||
"""
|
|
||||||
Generate prompt asking user what to do.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
detected: List of detected links
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Action prompt
|
|
||||||
"""
|
|
||||||
has_short = any(item['is_short'] for item in detected)
|
|
||||||
|
|
||||||
if has_short:
|
|
||||||
return f"{format_short_link_help()}\n\n其他链接是否立即处理?(y/n)"
|
|
||||||
|
|
||||||
count = len(detected)
|
|
||||||
if count == 1:
|
|
||||||
return "是否添加到收件箱并处理?(y/n)"
|
|
||||||
else:
|
|
||||||
return f"是否将上述 {count} 个内容添加到收件箱?(y/n)"
|
|
||||||
|
|
||||||
|
|
||||||
def detect_triggers(text: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
检测用户消息中的触发词。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: 用户消息
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
'save' | 'subscribe' | None
|
|
||||||
"""
|
|
||||||
# 检测"保存到知识库"
|
|
||||||
if TRIGGER_PATTERNS['save'].search(text):
|
|
||||||
return 'save'
|
|
||||||
|
|
||||||
# 检测"订阅到知识库"
|
|
||||||
if TRIGGER_PATTERNS['subscribe'].search(text):
|
|
||||||
return 'subscribe'
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def handle_save_to_kb(detected: List[Dict[str, str]]) -> str:
|
|
||||||
"""
|
|
||||||
处理"保存到知识库"命令:将链接添加到收件箱并处理。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
detected: 检测到的链接列表
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
处理结果消息
|
|
||||||
"""
|
|
||||||
if not detected:
|
|
||||||
return "❌ 未检测到任何链接,请提供要保存的链接"
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for item in detected:
|
|
||||||
if item['is_short']:
|
|
||||||
results.append(f"⚠️ 短链接无法处理: {item['url']}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 调用保存处理
|
|
||||||
success, msg = process_link(item['url'], item['platform'], auto_process=True)
|
|
||||||
results.append(msg)
|
|
||||||
|
|
||||||
return "\n".join(results)
|
|
||||||
|
|
||||||
|
|
||||||
def handle_subscribe_to_kb(detected: List[Dict[str, str]]) -> str:
|
|
||||||
"""
|
|
||||||
处理"订阅到知识库"命令:将链接添加到订阅列表。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
detected: 检测到的链接列表
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
处理结果消息
|
|
||||||
"""
|
|
||||||
if not detected:
|
|
||||||
return "❌ 未检测到任何链接,请提供要订阅的链接"
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for item in detected:
|
|
||||||
if item['platform'] != 'bilibili':
|
|
||||||
results.append(f"⚠️ 暂不支持订阅 {item['platform']}: {item['url']}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if item['is_short']:
|
|
||||||
results.append(f"⚠️ 短链接无法订阅: {item['url']}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 调用订阅命令
|
|
||||||
import subprocess
|
|
||||||
cmd = [
|
|
||||||
sys.executable,
|
|
||||||
os.path.join(os.path.dirname(__file__), 'kb_workflow.py'),
|
|
||||||
'--trigger', 'subscriptions',
|
|
||||||
'--add', 'bilibili', 'video', item['id'], item.get('url', '')
|
|
||||||
]
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
||||||
if result.returncode == 0:
|
|
||||||
results.append(f"✅ 已订阅: {item['id']}")
|
|
||||||
else:
|
|
||||||
results.append(f"❌ 订阅失败: {result.stderr}")
|
|
||||||
except Exception as e:
|
|
||||||
results.append(f"❌ 订阅失败: {e}")
|
|
||||||
|
|
||||||
return "\n".join(results)
|
|
||||||
|
|
||||||
|
|
||||||
def should_auto_process(text: str) -> bool:
|
|
||||||
"""
|
|
||||||
Check if user wants to auto-process based on their message.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: User's message
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if user indicates they want immediate processing
|
|
||||||
"""
|
|
||||||
auto_keywords = ['立即处理', '处理', 'process', '--process', '马上']
|
|
||||||
return any(keyword in text.lower() for keyword in auto_keywords)
|
|
||||||
|
|
||||||
|
|
||||||
def process_link(url: str, platform: str, auto_process: bool = False) -> Tuple[bool, str]:
|
|
||||||
"""
|
|
||||||
Process a single link (extract metadata, add to inbox).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: Content URL
|
|
||||||
platform: Platform name (bilibili/youtube/zhihu)
|
|
||||||
auto_process: Whether to immediately process after adding
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(success, message) tuple
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Import here to avoid issues if dependencies not available
|
|
||||||
from sources.bilibili import BilibiliAdapter
|
|
||||||
from models import Content, ContentStatus
|
|
||||||
from storage import Storage
|
|
||||||
|
|
||||||
# Only support Bilibili for now
|
|
||||||
if platform != 'bilibili':
|
|
||||||
return False, f"暂不支持 {platform},请手动添加"
|
|
||||||
|
|
||||||
# Handle short links
|
|
||||||
if 'b23.tv' in url:
|
|
||||||
return False, "短链接无法自动处理,请提供完整URL或BV号"
|
|
||||||
|
|
||||||
# Create adapter
|
|
||||||
adapter = BilibiliAdapter()
|
|
||||||
|
|
||||||
# Extract metadata
|
|
||||||
content = adapter.extract_content(url)
|
|
||||||
if not content:
|
|
||||||
return False, "无法提取视频信息"
|
|
||||||
|
|
||||||
# Get transcript
|
|
||||||
transcript = adapter.get_transcript(content)
|
|
||||||
if transcript:
|
|
||||||
content.transcript = transcript
|
|
||||||
|
|
||||||
# Save to inbox
|
|
||||||
content.status = ContentStatus.CONFIRMED
|
|
||||||
storage = Storage()
|
|
||||||
storage.save_content(content)
|
|
||||||
|
|
||||||
# Build success message
|
|
||||||
msg = f"✅ 已添加到收件箱\n"
|
|
||||||
msg += f" 标题: {content.title}\n"
|
|
||||||
msg += f" UP主: {content.author}\n"
|
|
||||||
msg += f" 时长: {content.duration}秒\n"
|
|
||||||
|
|
||||||
if auto_process:
|
|
||||||
# Process immediately
|
|
||||||
try:
|
|
||||||
from src.pipeline import ProcessingPipeline
|
|
||||||
pipeline = ProcessingPipeline()
|
|
||||||
content_text = content.transcript or content.summary or ""
|
|
||||||
result = pipeline.process_full(content, content_text)
|
|
||||||
storage.save_content(result)
|
|
||||||
|
|
||||||
msg += f"\n🔄 处理完成\n"
|
|
||||||
msg += f" 📄 笔记: {result.output_path or 'N/A'}\n"
|
|
||||||
msg += f" 🏷️ 标签: {', '.join(result.tags)}"
|
|
||||||
except Exception as e:
|
|
||||||
msg += f"\n⚠️ 处理失败: {e}"
|
|
||||||
|
|
||||||
return True, msg
|
|
||||||
|
|
||||||
except ImportError as e:
|
|
||||||
return False, f"依赖模块未安装: {e}"
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"处理失败: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def process_video_full(source_id: str, enable_git_sync: bool = False) -> Tuple[bool, str]:
|
|
||||||
"""
|
|
||||||
使用主流程完整处理视频(新接口)
|
|
||||||
|
|
||||||
完整流程: 提取 → 处理 → 输出 → 同步
|
|
||||||
|
|
||||||
Args:
|
|
||||||
source_id: 视频ID(支持BV号、URL)
|
|
||||||
enable_git_sync: 是否启用Git同步
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(success, message) tuple
|
|
||||||
"""
|
|
||||||
if not MAIN_PIPELINE_AVAILABLE:
|
|
||||||
return False, "主流程模块未可用,请检查依赖"
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 导入配置
|
|
||||||
from src.config import get_config
|
|
||||||
|
|
||||||
# 获取配置
|
|
||||||
config = get_config()
|
|
||||||
|
|
||||||
# 创建主流程
|
|
||||||
pipeline = MainPipeline(config=config, enable_git_sync=enable_git_sync)
|
|
||||||
|
|
||||||
# 处理视频
|
|
||||||
result = pipeline.process_video(source_id)
|
|
||||||
|
|
||||||
# 返回结果
|
|
||||||
if result['success']:
|
|
||||||
return True, result['message']
|
|
||||||
else:
|
|
||||||
return False, result.get('error', 'Unknown error')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"处理失败: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def handle_message(text: str) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Main entry point: handle incoming message and return response.
|
|
||||||
|
|
||||||
## 触发条件
|
|
||||||
- 检测到"保存到知识库"/"订阅到知识库"触发词 + 链接 → 自动处理
|
|
||||||
- 仅检测到链接 → 询问用户是否处理
|
|
||||||
|
|
||||||
Args:
|
|
||||||
text: User's message text
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response message, or None if no action needed
|
|
||||||
"""
|
|
||||||
# 首先检测触发词
|
|
||||||
trigger = detect_triggers(text)
|
|
||||||
|
|
||||||
# 检测链接
|
|
||||||
detected = detect_links(text)
|
|
||||||
|
|
||||||
# 如果没有链接,也没有触发词,返回 None
|
|
||||||
if not detected and not trigger:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 如果有触发词(save/subscribe),但没有链接
|
|
||||||
if trigger and not detected:
|
|
||||||
if trigger == 'save':
|
|
||||||
return "请提供要保存到知识库的链接"
|
|
||||||
elif trigger == 'subscribe':
|
|
||||||
return "请提供要订阅到知识库的链接"
|
|
||||||
|
|
||||||
# 处理"保存到知识库"触发
|
|
||||||
if trigger == 'save':
|
|
||||||
return handle_save_to_kb(detected)
|
|
||||||
|
|
||||||
# 处理"订阅到知识库"触发
|
|
||||||
if trigger == 'subscribe':
|
|
||||||
return handle_subscribe_to_kb(detected)
|
|
||||||
|
|
||||||
# 以下是原有的仅链接检测逻辑
|
|
||||||
# First, check for short links and provide help
|
|
||||||
has_short, short_msg = detect_and_format(text)
|
|
||||||
|
|
||||||
# If we have short links, show help message first
|
|
||||||
if has_short:
|
|
||||||
return short_msg
|
|
||||||
|
|
||||||
# Check if this is a confirmation (y/yes)
|
|
||||||
text_lower = text.lower().strip()
|
|
||||||
if text_lower in ('y', 'yes', '是', '好的', 'ok'):
|
|
||||||
# This would need state management - for now return a message
|
|
||||||
# about using explicit command
|
|
||||||
return "请使用 /kb-add <URL> 命令添加内容,或重新发送链接"
|
|
||||||
|
|
||||||
# Show preview
|
|
||||||
preview = format_preview(detected)
|
|
||||||
prompt = get_action_prompt(detected)
|
|
||||||
|
|
||||||
return f"{preview}\n{prompt}"
|
|
||||||
|
|
||||||
|
|
||||||
# CLI interface for testing
|
|
||||||
def main():
|
|
||||||
"""CLI for testing the handler."""
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
print("Usage: python3 handler.py <command> [options]")
|
|
||||||
print("\nCommands:")
|
|
||||||
print(" detect 'text' - 检测文本中的内容链接")
|
|
||||||
print(" process <BV/URL> - 使用主流程处理视频")
|
|
||||||
print(" process <BV/URL> --git - 处理视频并同步到Git")
|
|
||||||
print("\nExamples:")
|
|
||||||
print(' python3 handler.py detect "https://www.bilibili.com/video/BV1xx411c7mD"')
|
|
||||||
print(' python3 handler.py process BV1xx411c7mD')
|
|
||||||
print(' python3 handler.py process BV1xx411c7mD --git')
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
command = sys.argv[1].lower()
|
|
||||||
|
|
||||||
if command == 'detect':
|
|
||||||
# 检测链接
|
|
||||||
if len(sys.argv) < 3:
|
|
||||||
print("Error: Please provide text to detect")
|
|
||||||
sys.exit(1)
|
|
||||||
text = ' '.join(sys.argv[2:])
|
|
||||||
response = handle_message(text)
|
|
||||||
if response:
|
|
||||||
print(response)
|
|
||||||
else:
|
|
||||||
print("No content links detected")
|
|
||||||
|
|
||||||
elif command == 'process':
|
|
||||||
# 使用主流程处理视频
|
|
||||||
if len(sys.argv) < 3:
|
|
||||||
print("Error: Please provide BV number or URL")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
source_id = sys.argv[2]
|
|
||||||
enable_git = '--git' in sys.argv
|
|
||||||
|
|
||||||
print(f"Processing video: {source_id}")
|
|
||||||
if enable_git:
|
|
||||||
print("Git sync: enabled")
|
|
||||||
else:
|
|
||||||
print("Git sync: disabled")
|
|
||||||
print()
|
|
||||||
|
|
||||||
success, message = process_video_full(source_id, enable_git_sync=enable_git)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
print(message)
|
|
||||||
sys.exit(0)
|
|
||||||
else:
|
|
||||||
print(f"\n❌ Failed: {message}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
else:
|
|
||||||
# 兼容旧格式:直接传入文本进行检测
|
|
||||||
text = sys.argv[1]
|
|
||||||
response = handle_message(text)
|
|
||||||
if response:
|
|
||||||
print(response)
|
|
||||||
else:
|
|
||||||
print("No content links detected")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -1,489 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Obsidian KB - 主流程集成
|
|
||||||
|
|
||||||
整合提取层、处理层、输出层,实现完整的数据流:
|
|
||||||
提取(BilibiliExtractor) → 处理(DeepAnalyzer/ContentChunker) → 输出(ObsidianWriter) → 同步(GitSync)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import logging
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# 添加项目路径
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
|
|
||||||
from src.extractors.bilibili_extractor import BilibiliExtractor, ExtractionError
|
|
||||||
from src.deep_analyzer import DeepAnalyzer
|
|
||||||
from src.content_chunker import ContentChunker, Chunk
|
|
||||||
from src.outputs.obsidian_writer import ObsidianWriter
|
|
||||||
from src.outputs.git_sync import GitSync
|
|
||||||
from src.models import Content, ContentType, ContentStatus, TimelineEntry
|
|
||||||
from src.config import get_config
|
|
||||||
|
|
||||||
|
|
||||||
# 配置日志
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
||||||
handlers=[
|
|
||||||
logging.StreamHandler(sys.stdout),
|
|
||||||
logging.FileHandler('obsidian_kb.log', encoding='utf-8')
|
|
||||||
]
|
|
||||||
)
|
|
||||||
logger = logging.getLogger('ObsidianKB')
|
|
||||||
|
|
||||||
|
|
||||||
class MainPipeline:
|
|
||||||
"""主流程集成器"""
|
|
||||||
|
|
||||||
def __init__(self, config=None, enable_git_sync: bool = False):
|
|
||||||
"""
|
|
||||||
初始化主流程
|
|
||||||
|
|
||||||
Args:
|
|
||||||
config: 配置对象
|
|
||||||
enable_git_sync: 是否启用Git同步
|
|
||||||
"""
|
|
||||||
self.config = config or get_config()
|
|
||||||
self.enable_git_sync = enable_git_sync
|
|
||||||
|
|
||||||
logger.info("初始化主流程...")
|
|
||||||
logger.info(f"Obsidian vault 路径: {self.config.vault_path}")
|
|
||||||
logger.info(f"Git 同步: {'启用' if enable_git_sync else '禁用'}")
|
|
||||||
|
|
||||||
# 初始化各层组件
|
|
||||||
self.extractor = BilibiliExtractor(config=self.config)
|
|
||||||
self.analyzer = DeepAnalyzer(ai_client=None) # 暂时不传入ai_client
|
|
||||||
self.chunker = ContentChunker()
|
|
||||||
self.writer = ObsidianWriter(config=self.config)
|
|
||||||
|
|
||||||
# 初始化Git同步器(仅在需要时)
|
|
||||||
self.git_sync = None
|
|
||||||
if enable_git_sync and self._is_git_enabled():
|
|
||||||
self.git_sync = GitSync(config=self.config)
|
|
||||||
logger.info("Git 同步器已初始化")
|
|
||||||
|
|
||||||
def _is_git_enabled(self) -> bool:
|
|
||||||
"""检查Git同步是否在配置中启用"""
|
|
||||||
return self.config.get('git.enabled', False)
|
|
||||||
|
|
||||||
def process_video(self, source_id: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
处理单个视频的完整流程
|
|
||||||
|
|
||||||
Args:
|
|
||||||
source_id: 视频ID(支持BV号、URL)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
处理结果字典,包含:
|
|
||||||
- success: bool - 是否成功
|
|
||||||
- content: Content对象 - 处理的内容
|
|
||||||
- message: str - 处理消息
|
|
||||||
- error: str - 错误信息(如果失败)
|
|
||||||
"""
|
|
||||||
result = {
|
|
||||||
'success': False,
|
|
||||||
'content': None,
|
|
||||||
'message': '',
|
|
||||||
'error': ''
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.info("=" * 80)
|
|
||||||
logger.info(f"开始处理视频: {source_id}")
|
|
||||||
logger.info("=" * 80)
|
|
||||||
|
|
||||||
# Step 1: 提取层 - 提取视频信息
|
|
||||||
logger.info("\n[Step 1/5] 提取视频信息...")
|
|
||||||
extractor_data = self._extract_video(source_id)
|
|
||||||
|
|
||||||
# Step 2: 构建Content对象
|
|
||||||
logger.info("\n[Step 2/5] 构建内容对象...")
|
|
||||||
content = self._build_content(extractor_data)
|
|
||||||
|
|
||||||
# Step 3: 处理层 - 深度分析
|
|
||||||
logger.info("\n[Step 3/5] 深度分析...")
|
|
||||||
analysis_result = self._analyze_content(extractor_data)
|
|
||||||
|
|
||||||
# Step 4: 处理层 - 内容分片
|
|
||||||
logger.info("\n[Step 4/5] 内容分片...")
|
|
||||||
chunks = self._chunk_content(extractor_data)
|
|
||||||
|
|
||||||
# 合并分析结果和分片信息到content
|
|
||||||
self._merge_analysis_to_content(content, analysis_result, chunks)
|
|
||||||
|
|
||||||
# Step 5: 输出层 - 写入Obsidian
|
|
||||||
logger.info("\n[Step 5/5] 写入Obsidian...")
|
|
||||||
output_path = self._write_obsidian_note(content)
|
|
||||||
|
|
||||||
# Step 6: 同步层 - Git同步(可选)
|
|
||||||
if self.enable_git_sync and self.git_sync:
|
|
||||||
logger.info("\n[Step 6/6] Git同步...")
|
|
||||||
self._sync_to_git(content)
|
|
||||||
|
|
||||||
# 更新状态
|
|
||||||
content.update_status(ContentStatus.GENERATED)
|
|
||||||
content.output_path = output_path
|
|
||||||
|
|
||||||
# 构建成功结果
|
|
||||||
result['success'] = True
|
|
||||||
result['content'] = content
|
|
||||||
result['message'] = self._format_success_message(content, chunks)
|
|
||||||
|
|
||||||
logger.info("\n" + "=" * 80)
|
|
||||||
logger.info(f"✅ 处理完成: {content.title}")
|
|
||||||
logger.info(f" 📄 笔记路径: {output_path}")
|
|
||||||
logger.info(f" 📊 字幕条数: {len(chunks) if chunks else 0}")
|
|
||||||
logger.info("=" * 80 + "\n")
|
|
||||||
|
|
||||||
except ExtractionError as e:
|
|
||||||
logger.error(f"❌ 提取失败: {e}")
|
|
||||||
result['error'] = f"提取失败: {e}"
|
|
||||||
result['message'] = f"提取视频信息失败: {e}"
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"❌ 处理失败: {e}", exc_info=True)
|
|
||||||
result['error'] = str(e)
|
|
||||||
result['message'] = f"处理过程中发生错误: {e}"
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _extract_video(self, source_id: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
提取视频信息
|
|
||||||
|
|
||||||
Args:
|
|
||||||
source_id: 视频ID或URL
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
提取器返回的数据结构
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ExtractionError: 提取失败
|
|
||||||
"""
|
|
||||||
logger.info(f" 正在提取: {source_id}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
extractor_data = self.extractor.extract(source_id)
|
|
||||||
logger.info(f" ✅ 提取成功: {extractor_data.get('metadata', {}).get('title', '未知标题')}")
|
|
||||||
logger.info(f" 作者: {extractor_data.get('metadata', {}).get('author', '未知')}")
|
|
||||||
logger.info(f" 时长: {extractor_data.get('metadata', {}).get('duration', '0')}")
|
|
||||||
|
|
||||||
# 检查字幕
|
|
||||||
subtitle = extractor_data.get('subtitle', '')
|
|
||||||
if subtitle:
|
|
||||||
subtitle_lines = subtitle.split('\n')
|
|
||||||
logger.info(f" 字幕: {len(subtitle_lines)} 条")
|
|
||||||
else:
|
|
||||||
logger.warning(f" ⚠️ 无字幕")
|
|
||||||
|
|
||||||
return extractor_data
|
|
||||||
|
|
||||||
except ExtractionError as e:
|
|
||||||
logger.error(f" ❌ 提取失败: {e}")
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f" ❌ 提取异常: {e}", exc_info=True)
|
|
||||||
raise ExtractionError(f"提取过程异常: {e}", source_id=source_id)
|
|
||||||
|
|
||||||
def _build_content(self, extractor_data: Dict[str, Any]) -> Content:
|
|
||||||
"""
|
|
||||||
从提取器数据构建Content对象
|
|
||||||
|
|
||||||
Args:
|
|
||||||
extractor_data: 提取器返回的数据
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Content对象
|
|
||||||
"""
|
|
||||||
metadata = extractor_data.get('metadata', {})
|
|
||||||
|
|
||||||
content = Content(
|
|
||||||
source='bilibili',
|
|
||||||
source_id=metadata.get('bvid', ''),
|
|
||||||
url=metadata.get('url', ''),
|
|
||||||
title=metadata.get('title', ''),
|
|
||||||
author=metadata.get('author', ''),
|
|
||||||
description=extractor_data.get('description', ''),
|
|
||||||
duration=self._parse_duration(metadata.get('duration', '0')),
|
|
||||||
content_type=ContentType.VIDEO,
|
|
||||||
transcript=extractor_data.get('subtitle', ''),
|
|
||||||
status=ContentStatus.SCANNED
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(f" ✅ Content对象已创建: {content.id}")
|
|
||||||
return content
|
|
||||||
|
|
||||||
def _parse_duration(self, duration_str: str) -> Optional[int]:
|
|
||||||
"""
|
|
||||||
解析时长字符串为秒数
|
|
||||||
|
|
||||||
Args:
|
|
||||||
duration_str: 时长字符串(如 "10:30")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
秒数
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if ':' in duration_str:
|
|
||||||
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 None
|
|
||||||
|
|
||||||
def _analyze_content(self, extractor_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
深度分析内容
|
|
||||||
|
|
||||||
Args:
|
|
||||||
extractor_data: 提取器返回的数据
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
分析结果字典
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logger.info(" 正在进行深度分析...")
|
|
||||||
|
|
||||||
# 使用DeepAnalyzer进行分析
|
|
||||||
# DeepAnalyzer接收extractor_data作为输入
|
|
||||||
analysis_result = self.analyzer.analyze(extractor_data)
|
|
||||||
|
|
||||||
# 将AnalysisResult转换为字典以便合并
|
|
||||||
result_dict = {
|
|
||||||
'main_topics': analysis_result.main_topics,
|
|
||||||
'key_insights': analysis_result.key_insights,
|
|
||||||
'action_items': analysis_result.action_items,
|
|
||||||
'summary': analysis_result.summary,
|
|
||||||
'timeline': analysis_result.timeline,
|
|
||||||
'recommended_tags': analysis_result.recommended_tags,
|
|
||||||
'full_analysis': analysis_result.full_analysis
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(f" ✅ 分析完成: {len(result_dict['main_topics'])} 个主题")
|
|
||||||
|
|
||||||
return result_dict
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f" ❌ 分析失败: {e}", exc_info=True)
|
|
||||||
# 返回默认结果,不中断流程
|
|
||||||
return {
|
|
||||||
'main_topics': ['未分类'],
|
|
||||||
'key_insights': [],
|
|
||||||
'action_items': [],
|
|
||||||
'summary': '分析失败,无法生成摘要',
|
|
||||||
'timeline': [],
|
|
||||||
'recommended_tags': ['未分类'],
|
|
||||||
'full_analysis': ''
|
|
||||||
}
|
|
||||||
|
|
||||||
def _chunk_content(self, extractor_data: Dict[str, Any]) -> list:
|
|
||||||
"""
|
|
||||||
内容分片
|
|
||||||
|
|
||||||
Args:
|
|
||||||
extractor_data: 提取器返回的数据
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Chunk对象列表
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logger.info(" 正在进行内容分片...")
|
|
||||||
|
|
||||||
# 使用ContentChunker进行分片
|
|
||||||
chunks = self.chunker.chunk_from_extractor(extractor_data)
|
|
||||||
|
|
||||||
logger.info(f" ✅ 分片完成: {len(chunks)} 段")
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f" ❌ 分片失败: {e}", exc_info=True)
|
|
||||||
# 返回空列表,不中断流程
|
|
||||||
return []
|
|
||||||
|
|
||||||
def _merge_analysis_to_content(self, content: Content, analysis_result: Dict[str, Any], chunks: list):
|
|
||||||
"""
|
|
||||||
将分析结果和分片信息合并到Content对象
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content: Content对象
|
|
||||||
analysis_result: 分析结果字典
|
|
||||||
chunks: Chunk对象列表
|
|
||||||
"""
|
|
||||||
# 合并基本信息
|
|
||||||
content.summary = analysis_result.get('summary', '')
|
|
||||||
content.key_points = analysis_result.get('main_topics', [])
|
|
||||||
content.tags = analysis_result.get('recommended_tags', [])
|
|
||||||
|
|
||||||
# 合并时间轴
|
|
||||||
timeline_data = analysis_result.get('timeline', [])
|
|
||||||
if timeline_data:
|
|
||||||
content.timeline = [
|
|
||||||
TimelineEntry(
|
|
||||||
timestamp=item.get('start_time', ''),
|
|
||||||
title=item.get('title', ''),
|
|
||||||
description=item.get('summary', '')
|
|
||||||
)
|
|
||||||
for item in timeline_data
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
# 从chunks生成时间轴
|
|
||||||
if chunks:
|
|
||||||
content.timeline = [
|
|
||||||
TimelineEntry(
|
|
||||||
timestamp=chunk.time_range,
|
|
||||||
title=f"片段 {chunk.index + 1}",
|
|
||||||
description=f"时长 {int(chunk.duration)}秒"
|
|
||||||
)
|
|
||||||
for chunk in chunks
|
|
||||||
]
|
|
||||||
|
|
||||||
logger.info(f" ✅ 数据合并完成: 时间轴 {len(content.timeline)} 条")
|
|
||||||
|
|
||||||
def _write_obsidian_note(self, content: Content) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
写入Obsidian笔记
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content: Content对象
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
文件路径,失败返回None
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
logger.info(f" 正在写入笔记...")
|
|
||||||
|
|
||||||
# 确保输出目录存在
|
|
||||||
self.config.vault_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# 调用ObsidianWriter写入
|
|
||||||
output_path = self.writer.write(content, self.config)
|
|
||||||
|
|
||||||
if output_path:
|
|
||||||
logger.info(f" ✅ 笔记已写入: {output_path}")
|
|
||||||
else:
|
|
||||||
logger.warning(f" ⚠️ 笔记写入失败")
|
|
||||||
|
|
||||||
return output_path
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f" ❌ 写入失败: {e}", exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _sync_to_git(self, content: Content):
|
|
||||||
"""
|
|
||||||
同步到Git仓库
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content: Content对象
|
|
||||||
"""
|
|
||||||
if not self.git_sync:
|
|
||||||
logger.warning(" ⚠️ Git同步器未初始化,跳过同步")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.info(" 正在同步到Git...")
|
|
||||||
|
|
||||||
success = self.git_sync.sync(self.config)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
logger.info(" ✅ Git同步成功")
|
|
||||||
else:
|
|
||||||
logger.warning(" ⚠️ Git同步失败")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f" ❌ Git同步异常: {e}", exc_info=True)
|
|
||||||
# Git同步失败不视为整体失败
|
|
||||||
|
|
||||||
def _format_success_message(self, content: Content, chunks: list) -> str:
|
|
||||||
"""
|
|
||||||
格式化成功消息
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content: Content对象
|
|
||||||
chunks: Chunk列表
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
格式化的消息字符串
|
|
||||||
"""
|
|
||||||
lines = [
|
|
||||||
"✅ 处理完成!",
|
|
||||||
"",
|
|
||||||
f"📺 标题: {content.title}",
|
|
||||||
f"👤 作者: {content.author}",
|
|
||||||
f"📄 笔记: {content.output_path or 'N/A'}",
|
|
||||||
]
|
|
||||||
|
|
||||||
if content.duration:
|
|
||||||
lines.append(f"⏱️ 时长: {self._format_duration(content.duration)}")
|
|
||||||
|
|
||||||
if chunks:
|
|
||||||
lines.append(f"📊 分片: {len(chunks)} 段")
|
|
||||||
|
|
||||||
if content.tags:
|
|
||||||
lines.append(f"🏷️ 标签: {', '.join(content.tags[:5])}")
|
|
||||||
|
|
||||||
lines.append("")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
def _format_duration(self, seconds: int) -> str:
|
|
||||||
"""格式化时长"""
|
|
||||||
if not seconds:
|
|
||||||
return "N/A"
|
|
||||||
|
|
||||||
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}秒"
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""命令行入口"""
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description='Obsidian KB - 主流程集成')
|
|
||||||
parser.add_argument('source_id', help='视频ID(BV号或URL)')
|
|
||||||
parser.add_argument('--git', action='store_true', help='启用Git同步')
|
|
||||||
parser.add_argument('--config', help='配置文件路径')
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# 加载配置
|
|
||||||
config = get_config(args.config) if args.config else get_config()
|
|
||||||
|
|
||||||
# 创建主流程
|
|
||||||
pipeline = MainPipeline(config=config, enable_git_sync=args.git)
|
|
||||||
|
|
||||||
# 处理视频
|
|
||||||
result = pipeline.process_video(args.source_id)
|
|
||||||
|
|
||||||
# 输出结果
|
|
||||||
print(result['message'])
|
|
||||||
|
|
||||||
if not result['success']:
|
|
||||||
print(f"\n❌ 失败: {result['error']}")
|
|
||||||
sys.exit(1)
|
|
||||||
else:
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -22,11 +22,13 @@ from bs4 import BeautifulSoup
|
|||||||
|
|
||||||
from .base import Extractor, ExtractionError
|
from .base import Extractor, ExtractionError
|
||||||
|
|
||||||
# 导入 shortlink_resolver
|
# 导入 kb-link-parser 用于短链解析
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
# 添加 kb-link-parser 到导入路径
|
||||||
from shortlink_resolver import ShortLinkResolver
|
KB_LINK_PARSER_PATH = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||||
|
sys.path.insert(0, os.path.join(KB_LINK_PARSER_PATH, 'kb-link-parser'))
|
||||||
|
from link_parser_shortlink import ShortLinkResolver
|
||||||
from .hard_subtitle_extractor import HardSubtitleExtractor
|
from .hard_subtitle_extractor import HardSubtitleExtractor
|
||||||
|
|
||||||
# 尝试导入 stealth 支持
|
# 尝试导入 stealth 支持
|
||||||
|
|||||||
Reference in New Issue
Block a user