自动同步: 2026-03-18 02:10
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
"""Processors module for KB Pipeline
|
||||
|
||||
This module contains processors for various content transformation tasks:
|
||||
- BaseProcessor: Abstract base class for all processors
|
||||
- ParseProcessor: URL parsing for various platforms
|
||||
- DownloadProcessor: Metadata and content downloading
|
||||
- ASRProcessor: Audio-to-text conversion (subtitle download + Whisper.cpp)
|
||||
- AnalyzeProcessor: AI-powered content analysis using DeepAnalyzer
|
||||
- ArchiveProcessor: Copy analysis reports to archive directory
|
||||
"""
|
||||
|
||||
from kb_workflow.pipeline.processors.base import BaseProcessor
|
||||
from kb_workflow.pipeline.processors.parse import ParseProcessor
|
||||
from kb_workflow.pipeline.processors.download import DownloadProcessor
|
||||
from kb_workflow.pipeline.processors.asr import ASRProcessor
|
||||
from kb_workflow.pipeline.processors.analyze import AnalyzeProcessor
|
||||
from kb_workflow.pipeline.processors.archive import ArchiveProcessor
|
||||
|
||||
__all__ = [
|
||||
"BaseProcessor",
|
||||
"ParseProcessor",
|
||||
"DownloadProcessor",
|
||||
"ASRProcessor",
|
||||
"AnalyzeProcessor",
|
||||
"ArchiveProcessor",
|
||||
]
|
||||
@@ -0,0 +1,313 @@
|
||||
"""AnalyzeProcessor - AI-powered content analysis
|
||||
|
||||
Unified processor for generating AI analysis reports from content.
|
||||
Wraps existing DeepAnalyzer for integration into the pipeline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from kb_workflow.pipeline.config import get_settings
|
||||
from kb_workflow.pipeline.schemas import AnalyzeInput, AnalyzeOutput
|
||||
from kb_workflow.pipeline.state import WorkflowStateManager
|
||||
from kb_workflow.pipeline.processors.base import BaseProcessor
|
||||
from kb_workflow.deep_analyzer import DeepAnalyzer, SimpleAIClient, generate_frontmatter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnalyzeProcessor(BaseProcessor):
|
||||
"""Analyze 处理器 - AI 深度内容分析
|
||||
|
||||
处理流程:
|
||||
1. 检查步骤是否已完成(幂等执行)
|
||||
2. 加载元数据和内容文件
|
||||
3. 初始化 AI 客户端
|
||||
4. 调用 DeepAnalyzer 生成分析报告
|
||||
5. 生成 frontmatter 并保存 Markdown 文件
|
||||
6. 记录处理状态到 workflow_state.json
|
||||
|
||||
Attributes:
|
||||
STEP_NAME: 步骤名称,用于状态记录
|
||||
settings: KB 配置
|
||||
state_manager: 状态管理器(可选)
|
||||
"""
|
||||
|
||||
STEP_NAME = "analyze"
|
||||
|
||||
async def process(self, input_data: AnalyzeInput) -> AnalyzeOutput:
|
||||
"""执行分析处理
|
||||
|
||||
策略:
|
||||
1. 检查步骤是否已完成(跳过已完成)
|
||||
2. 加载元数据和内容
|
||||
3. 调用 DeepAnalyzer
|
||||
4. 保存分析报告
|
||||
5. 记录状态
|
||||
|
||||
Args:
|
||||
input_data: Analyze 输入数据
|
||||
|
||||
Returns:
|
||||
Analyze 处理结果
|
||||
|
||||
Raises:
|
||||
Exception: 处理失败时抛出异常
|
||||
"""
|
||||
try:
|
||||
# Step 1: 检查是否已完成(幂等执行)
|
||||
if self.state_manager:
|
||||
state = self.state_manager.load()
|
||||
if state and state.is_step_completed(self.STEP_NAME):
|
||||
logger.info(f"Step {self.STEP_NAME} already completed, skipping")
|
||||
# 返回已有结果
|
||||
existing_file = Path(input_data.output_file)
|
||||
if existing_file.exists():
|
||||
return AnalyzeOutput(
|
||||
success=True,
|
||||
analysis_file=str(existing_file),
|
||||
summary="Skipped - already completed",
|
||||
main_topics=[],
|
||||
key_insights=[],
|
||||
tags=[],
|
||||
stats={"skipped": True}
|
||||
)
|
||||
|
||||
# Step 2: 加载元数据和内容
|
||||
metadata = self._load_json(input_data.metadata_file)
|
||||
content = self._load_json(input_data.content_file)
|
||||
|
||||
# 处理 Whisper 字幕格式 - 提取完整文本
|
||||
if content and 'transcription' in content:
|
||||
# Whisper JSON 格式,提取文本
|
||||
text_parts = []
|
||||
for item in content.get('transcription', []):
|
||||
text_parts.append(item.get('text', ''))
|
||||
content = {'text': ' '.join(text_parts)}
|
||||
logger.info(f"Extracted {len(content['text'])} chars from Whisper transcription")
|
||||
|
||||
# 对于文章类型,内容可能是纯文本文件
|
||||
if not content and input_data.content_type in ("article", "answer", "zhuanlan"):
|
||||
content_path = Path(input_data.content_file)
|
||||
if content_path.exists():
|
||||
text = content_path.read_text(encoding="utf-8")
|
||||
# 去除 YAML frontmatter(如果存在)
|
||||
text = self._strip_frontmatter(text)
|
||||
content = {"text": text}
|
||||
|
||||
# Step 3: 调用 DeepAnalyzer
|
||||
result = await self._run_analysis(
|
||||
metadata=metadata,
|
||||
content=content,
|
||||
content_type=input_data.content_type
|
||||
)
|
||||
|
||||
# Step 4: 生成并保存分析报告
|
||||
output_path = Path(input_data.output_file)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._write_analysis_report(
|
||||
output_path=output_path,
|
||||
metadata=metadata,
|
||||
result=result,
|
||||
content_type=input_data.content_type
|
||||
)
|
||||
|
||||
# Step 5: 记录成功状态
|
||||
if self.state_manager:
|
||||
state = self.state_manager.load()
|
||||
if state:
|
||||
summary = f"topics={len(result.main_topics)}, insights={len(result.key_insights)}"
|
||||
state.mark_step_completed(self.STEP_NAME, summary)
|
||||
self.state_manager.save(state)
|
||||
|
||||
return AnalyzeOutput(
|
||||
success=True,
|
||||
analysis_file=str(output_path),
|
||||
summary=result.summary,
|
||||
main_topics=result.main_topics,
|
||||
key_insights=result.key_insights,
|
||||
tags=result.recommended_tags,
|
||||
stats={
|
||||
"topics_count": len(result.main_topics),
|
||||
"insights_count": len(result.key_insights),
|
||||
"tags_count": len(result.recommended_tags)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Analyze failed: {e}")
|
||||
|
||||
# 记录失败状态
|
||||
if self.state_manager:
|
||||
state = self.state_manager.load()
|
||||
if state:
|
||||
state.mark_step_failed(self.STEP_NAME, str(e))
|
||||
self.state_manager.save(state)
|
||||
|
||||
return AnalyzeOutput(
|
||||
success=False,
|
||||
error_message=str(e),
|
||||
analysis_file="",
|
||||
summary="",
|
||||
main_topics=[],
|
||||
key_insights=[],
|
||||
tags=[],
|
||||
stats={}
|
||||
)
|
||||
|
||||
def _load_json(self, file_path: str) -> Dict[str, Any]:
|
||||
"""加载 JSON 文件
|
||||
|
||||
Args:
|
||||
file_path: JSON 文件路径
|
||||
|
||||
Returns:
|
||||
解析后的字典,如果文件不存在或解析失败返回空字典
|
||||
"""
|
||||
if not file_path:
|
||||
return {}
|
||||
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
logger.warning(f"File not found: {file_path}")
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse JSON: {file_path}, error: {e}")
|
||||
return {}
|
||||
|
||||
async def _run_analysis(self, metadata: Dict, content: Dict, content_type: str):
|
||||
"""调用 DeepAnalyzer 执行分析
|
||||
|
||||
Args:
|
||||
metadata: 元数据字典
|
||||
content: 内容字典
|
||||
content_type: 内容类型
|
||||
|
||||
Returns:
|
||||
AnalysisResult 对象
|
||||
"""
|
||||
# 初始化 AI 客户端
|
||||
ai_client = self._create_ai_client()
|
||||
|
||||
# 创建分析器实例
|
||||
analyzer = DeepAnalyzer(ai_client=ai_client)
|
||||
|
||||
# 执行分析
|
||||
return analyzer.analyze(metadata, content, content_type)
|
||||
|
||||
def _create_ai_client(self):
|
||||
"""创建 AI 客户端
|
||||
|
||||
Returns:
|
||||
SimpleAIClient 实例
|
||||
"""
|
||||
# 从环境变量获取配置
|
||||
api_key = os.environ.get("AI_API_KEY", "")
|
||||
provider = os.environ.get("AI_PROVIDER", "")
|
||||
model = os.environ.get("AI_MODEL", "glm-5")
|
||||
base_url = os.environ.get("AI_BASE_URL", "")
|
||||
|
||||
return SimpleAIClient(
|
||||
api_key=api_key,
|
||||
provider=provider,
|
||||
model=model,
|
||||
base_url=base_url
|
||||
)
|
||||
|
||||
def _normalize_metadata(self, metadata: Dict) -> Dict:
|
||||
"""标准化元数据结构(扁平化嵌套结构)
|
||||
|
||||
Args:
|
||||
metadata: 原始元数据字典,可能是嵌套结构
|
||||
|
||||
Returns:
|
||||
扁平化的元数据字典
|
||||
"""
|
||||
if not metadata:
|
||||
return {}
|
||||
|
||||
# 如果已经是扁平结构(顶层有 title)
|
||||
if 'title' in metadata:
|
||||
return metadata
|
||||
|
||||
# 处理嵌套结构 {metadata: {...}, stats: {...}}
|
||||
if 'metadata' in metadata:
|
||||
return metadata['metadata'].copy()
|
||||
|
||||
return metadata
|
||||
|
||||
def _strip_frontmatter(self, text: str) -> str:
|
||||
"""去除 Markdown 文件中的 YAML frontmatter
|
||||
|
||||
Args:
|
||||
text: 可能包含 frontmatter 的文本
|
||||
|
||||
Returns:
|
||||
去除 frontmatter 后的文本
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
# 检查是否以 --- 开头
|
||||
if not text.startswith('---'):
|
||||
return text
|
||||
|
||||
# 找到第二个 ---
|
||||
lines = text.split('\n')
|
||||
end_index = -1
|
||||
for i in range(1, len(lines)):
|
||||
if lines[i].strip() == '---':
|
||||
end_index = i
|
||||
break
|
||||
|
||||
if end_index == -1:
|
||||
return text
|
||||
|
||||
# 返回 frontmatter 之后的内容(跳过空行)
|
||||
remaining = '\n'.join(lines[end_index + 1:])
|
||||
return remaining.lstrip('\n')
|
||||
|
||||
def _write_analysis_report(
|
||||
self,
|
||||
output_path: Path,
|
||||
metadata: Dict,
|
||||
result,
|
||||
content_type: str
|
||||
) -> None:
|
||||
"""写入分析报告 Markdown 文件
|
||||
|
||||
Args:
|
||||
output_path: 输出文件路径
|
||||
metadata: 元数据字典
|
||||
result: AnalysisResult 对象
|
||||
content_type: 内容类型
|
||||
"""
|
||||
# 标准化元数据(扁平化嵌套结构)
|
||||
normalized_metadata = self._normalize_metadata(metadata)
|
||||
|
||||
# 生成 frontmatter
|
||||
frontmatter = generate_frontmatter(normalized_metadata, result, content_type)
|
||||
|
||||
# 获取完整分析内容
|
||||
full_analysis = result.full_analysis
|
||||
|
||||
# 文章/回答类型:追加原文
|
||||
if content_type in ("article", "answer", "zhuanlan") and result.original_text:
|
||||
full_analysis = full_analysis.rstrip() + "\n\n## 原文\n\n" + result.original_text
|
||||
|
||||
# 写入文件
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
f.write(frontmatter + "\n\n")
|
||||
f.write(full_analysis)
|
||||
|
||||
logger.info(f"Analysis report saved to: {output_path}")
|
||||
@@ -0,0 +1,170 @@
|
||||
"""ArchiveProcessor - Copy analysis reports to archive directory
|
||||
|
||||
Final step in pipeline that copies the generated analysis.md
|
||||
to the archive directory organized by platform.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from kb_workflow.pipeline.config import get_settings
|
||||
from kb_workflow.pipeline.schemas import ArchiveInput, ArchiveOutput
|
||||
from kb_workflow.pipeline.state import WorkflowStateManager
|
||||
from kb_workflow.pipeline.processors.base import BaseProcessor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ArchiveProcessor(BaseProcessor):
|
||||
"""Archive 处理器 - 归档分析报告
|
||||
|
||||
处理流程:
|
||||
1. 检查步骤是否已完成(幂等执行)
|
||||
2. 检查归档目录是否存在相同 content_id 的文件
|
||||
3. 如果存在重复,询问用户是否覆盖
|
||||
4. 复制 md 文件到归档目录
|
||||
5. 记录处理状态到 workflow_state.json
|
||||
|
||||
归档目录结构:
|
||||
{KB_ARCHIVE_PATH}/{platform}/{YYYYMMDD}_{content_id}_analysis.md
|
||||
"""
|
||||
|
||||
STEP_NAME = "archive"
|
||||
|
||||
async def process(self, input_data: ArchiveInput) -> ArchiveOutput:
|
||||
"""执行归档处理
|
||||
|
||||
Args:
|
||||
input_data: Archive 输入数据
|
||||
|
||||
Returns:
|
||||
Archive 处理结果
|
||||
"""
|
||||
try:
|
||||
# Step 1: 检查是否已完成(幂等执行)
|
||||
if self.state_manager:
|
||||
state = self.state_manager.load()
|
||||
if state and state.is_step_completed(self.STEP_NAME):
|
||||
logger.info(f"Step {self.STEP_NAME} already completed, skipping")
|
||||
return ArchiveOutput(
|
||||
success=True,
|
||||
skipped=True,
|
||||
platform=input_data.platform,
|
||||
archive_file=""
|
||||
)
|
||||
|
||||
# Step 2: 准备归档路径
|
||||
archive_base = Path(self.settings.archive_path)
|
||||
platform_dir = archive_base / input_data.platform
|
||||
platform_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 文件命名: {YYYYMMDD}_{content_id}_analysis.md
|
||||
timestamp = datetime.now().strftime("%Y%m%d")
|
||||
archive_filename = f"{timestamp}_{input_data.content_id}_analysis.md"
|
||||
archive_path = platform_dir / archive_filename
|
||||
|
||||
# Step 3: 检查重复
|
||||
existing_file = self._find_existing_archive(
|
||||
platform_dir, input_data.content_id
|
||||
)
|
||||
if existing_file:
|
||||
should_overwrite = self._ask_overwrite(existing_file)
|
||||
if not should_overwrite:
|
||||
logger.info(f"User chose not to overwrite, skipping archive")
|
||||
return ArchiveOutput(
|
||||
success=True,
|
||||
skipped=True,
|
||||
platform=input_data.platform,
|
||||
archive_file=str(existing_file)
|
||||
)
|
||||
# 删除旧文件
|
||||
existing_file.unlink()
|
||||
logger.info(f"Removed existing archive: {existing_file}")
|
||||
|
||||
# Step 4: 复制文件
|
||||
source_path = Path(input_data.analysis_file)
|
||||
if not source_path.exists():
|
||||
raise FileNotFoundError(f"Analysis file not found: {source_path}")
|
||||
|
||||
shutil.copy2(source_path, archive_path)
|
||||
logger.info(f"Archived: {source_path} -> {archive_path}")
|
||||
|
||||
# Step 5: 记录成功状态
|
||||
if self.state_manager:
|
||||
state = self.state_manager.load()
|
||||
if state:
|
||||
state.mark_step_completed(
|
||||
self.STEP_NAME,
|
||||
f"archived to {archive_path}"
|
||||
)
|
||||
self.state_manager.save(state)
|
||||
|
||||
return ArchiveOutput(
|
||||
success=True,
|
||||
skipped=False,
|
||||
platform=input_data.platform,
|
||||
archive_file=str(archive_path)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Archive failed: {e}")
|
||||
|
||||
if self.state_manager:
|
||||
state = self.state_manager.load()
|
||||
if state:
|
||||
state.mark_step_failed(self.STEP_NAME, str(e))
|
||||
self.state_manager.save(state)
|
||||
|
||||
return ArchiveOutput(
|
||||
success=False,
|
||||
error_message=str(e),
|
||||
platform=input_data.platform,
|
||||
archive_file=""
|
||||
)
|
||||
|
||||
def _find_existing_archive(self, platform_dir: Path, content_id: str) -> Optional[Path]:
|
||||
"""查找已存在的归档文件
|
||||
|
||||
使用 glob 模式 `*_{content_id}_analysis.md` 搜索
|
||||
|
||||
Args:
|
||||
platform_dir: 平台目录路径
|
||||
content_id: 内容 ID
|
||||
|
||||
Returns:
|
||||
已存在的文件路径,如果不存在返回 None
|
||||
"""
|
||||
pattern = f"*_{content_id}_analysis.md"
|
||||
matches = list(platform_dir.glob(pattern))
|
||||
return matches[0] if matches else None
|
||||
|
||||
def _ask_overwrite(self, existing_file: Path) -> bool:
|
||||
"""询问用户是否覆盖已存在的文件
|
||||
|
||||
使用 rich.prompt.Confirm 模式(与 Pipeline._ask_rerun_step 相同)
|
||||
|
||||
Args:
|
||||
existing_file: 已存在的文件路径
|
||||
|
||||
Returns:
|
||||
True 如果用户选择覆盖
|
||||
"""
|
||||
try:
|
||||
from rich.console import Console
|
||||
from rich.prompt import Confirm
|
||||
|
||||
console = Console()
|
||||
return Confirm.ask(
|
||||
f"Archive already exists: {existing_file.name}\nOverwrite?",
|
||||
default=False
|
||||
)
|
||||
except ImportError:
|
||||
response = input(
|
||||
f"Archive already exists: {existing_file.name}\nOverwrite? [y/N] "
|
||||
)
|
||||
return response.lower() in ('y', 'yes')
|
||||
@@ -0,0 +1,685 @@
|
||||
"""ASRProcessor - Audio-to-text conversion
|
||||
|
||||
Unified processor for B站官方字幕下载 and Whisper.cpp 语音识别.
|
||||
Follows the pipeline pattern established in Phase 1/2.
|
||||
|
||||
Strategy:
|
||||
1. Try official subtitle first (highest quality, no compute cost)
|
||||
2. Fall back to Whisper.cpp if no subtitle available
|
||||
3. Auto-enable speaker diarization if HF_TOKEN is configured
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
import aiohttp
|
||||
|
||||
from kb_workflow.pipeline.config import get_settings
|
||||
from kb_workflow.pipeline.schemas import ASRInput, ASROutput
|
||||
from kb_workflow.pipeline.state import WorkflowStateManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WhisperNotFoundError(RuntimeError):
|
||||
"""Whisper.cpp 路径未找到"""
|
||||
pass
|
||||
|
||||
|
||||
def get_whisper_cpp_path() -> str:
|
||||
"""获取 whisper.cpp 安装路径
|
||||
|
||||
检查环境变量 WHISPER_CPP_PATH 或默认路径 ~/whisper.cpp
|
||||
|
||||
Returns:
|
||||
whisper.cpp 目录路径
|
||||
|
||||
Raises:
|
||||
WhisperNotFoundError: 如果路径不存在
|
||||
"""
|
||||
# 检查环境变量
|
||||
path = os.environ.get('WHISPER_CPP_PATH')
|
||||
if path and os.path.exists(path):
|
||||
return os.path.expanduser(path)
|
||||
|
||||
# 检查默认路径
|
||||
default = os.path.expanduser('~/whisper.cpp')
|
||||
if os.path.exists(default):
|
||||
return default
|
||||
|
||||
raise WhisperNotFoundError(
|
||||
"未找到 whisper.cpp。请设置 WHISPER_CPP_PATH 环境变量或安装到 ~/whisper.cpp"
|
||||
)
|
||||
|
||||
|
||||
def convert_to_wav(input_path: str, output_path: Optional[str] = None) -> str:
|
||||
"""使用 ffmpeg 将音频/视频转换为 16kHz WAV 格式
|
||||
|
||||
Args:
|
||||
input_path: 输入文件路径
|
||||
output_path: 输出文件路径(可选,默认为 input_path_audio.wav)
|
||||
|
||||
Returns:
|
||||
输出 WAV 文件路径
|
||||
"""
|
||||
if output_path is None:
|
||||
base = os.path.splitext(input_path)[0]
|
||||
output_path = f"{base}_audio.wav"
|
||||
|
||||
cmd = [
|
||||
'ffmpeg', '-y', '-i', input_path,
|
||||
'-ar', '16000', # 16kHz 采样率
|
||||
'-ac', '1', # 单声道
|
||||
'-c:a', 'pcm_s16le', # 16-bit PCM
|
||||
output_path
|
||||
]
|
||||
|
||||
subprocess.run(cmd, capture_output=True, check=True)
|
||||
return output_path
|
||||
|
||||
|
||||
class ASRProcessor:
|
||||
"""ASR 处理器 - 统一字幕下载和语音识别
|
||||
|
||||
处理流程:
|
||||
1. 如果是 B站视频,尝试下载官方字幕
|
||||
2. 无官方字幕则调用 Whisper.cpp
|
||||
3. HF_TOKEN 配置时自动启用说话者分离
|
||||
4. 记录处理状态到 workflow_state.json
|
||||
|
||||
Attributes:
|
||||
STEP_NAME: 步骤名称,用于状态记录
|
||||
settings: KB 配置
|
||||
state_manager: 状态管理器(可选)
|
||||
"""
|
||||
|
||||
STEP_NAME = "asr"
|
||||
|
||||
def __init__(self, state_manager: Optional[WorkflowStateManager] = None):
|
||||
"""初始化 ASR 处理器
|
||||
|
||||
Args:
|
||||
state_manager: 状态管理器(可选,用于记录处理状态)
|
||||
"""
|
||||
self.settings = get_settings()
|
||||
self.state_manager = state_manager
|
||||
|
||||
async def process(self, input_data: ASRInput) -> ASROutput:
|
||||
"""执行 ASR 处理
|
||||
|
||||
策略:
|
||||
0. 先检查是否已有字幕文件(幂等性)
|
||||
1. 先尝试官方字幕(仅 B站)
|
||||
2. 无官方字幕则调用 Whisper
|
||||
3. 记录状态
|
||||
|
||||
Args:
|
||||
input_data: ASR 输入数据
|
||||
|
||||
Returns:
|
||||
ASR 处理结果
|
||||
|
||||
Raises:
|
||||
Exception: 处理失败时抛出异常
|
||||
"""
|
||||
result = None
|
||||
|
||||
try:
|
||||
# Step 0: 检查是否已有字幕文件(幂等性)
|
||||
existing_result = self._check_existing_subtitle_files(input_data)
|
||||
if existing_result:
|
||||
logger.info(f"Found existing subtitle files, skipping processing")
|
||||
result = existing_result
|
||||
else:
|
||||
# Step 1: 尝试官方字幕(仅 B站)
|
||||
if input_data.platform == "bilibili":
|
||||
result = await self._try_official_subtitle(input_data)
|
||||
|
||||
# Step 2: 无官方字幕则调用 Whisper
|
||||
if result is None:
|
||||
result = await self._run_whisper(input_data)
|
||||
|
||||
# Step 3: 记录成功状态
|
||||
if self.state_manager:
|
||||
state = self.state_manager.load()
|
||||
if state:
|
||||
summary = f"tool={result.tool}, segments={len(result.segments)}"
|
||||
if result.speakers:
|
||||
summary += f", speakers={len(result.speakers)}"
|
||||
state.mark_step_completed(self.STEP_NAME, summary)
|
||||
self.state_manager.save(state)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
# 记录失败状态(只在所有重试都失败后)
|
||||
if self.state_manager:
|
||||
state = self.state_manager.load()
|
||||
if state:
|
||||
state.mark_step_failed(self.STEP_NAME, str(e))
|
||||
self.state_manager.save(state)
|
||||
raise
|
||||
|
||||
def _check_existing_subtitle_files(self, input_data: ASRInput) -> Optional[ASROutput]:
|
||||
"""检查是否已存在字幕文件(幂等性检查)
|
||||
|
||||
Args:
|
||||
input_data: ASR 输入数据
|
||||
|
||||
Returns:
|
||||
如果找到现有字幕文件返回 ASROutput,否则返回 None
|
||||
"""
|
||||
output_dir = Path(input_data.content_dir)
|
||||
|
||||
# 尝试两种命名格式:新格式(带 _subtitle 后缀)和旧格式
|
||||
base_names = [
|
||||
f"{input_data.platform}_{input_data.content_id}_subtitle",
|
||||
f"{input_data.platform}_{input_data.content_id}",
|
||||
]
|
||||
|
||||
for base_name in base_names:
|
||||
json_path = output_dir / f"{base_name}.json"
|
||||
srt_path = output_dir / f"{base_name}.srt"
|
||||
txt_path = output_dir / f"{base_name}.txt"
|
||||
|
||||
if json_path.exists():
|
||||
# 读取现有字幕数据
|
||||
segments = []
|
||||
text = ""
|
||||
duration = 0.0
|
||||
|
||||
try:
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# 解析字幕格式
|
||||
if 'transcription' in data:
|
||||
# Whisper 格式
|
||||
for i, item in enumerate(data['transcription']):
|
||||
offsets = item.get('offsets', {})
|
||||
if isinstance(offsets, dict):
|
||||
# 新格式:{"from": 0, "to": 5320}
|
||||
start = offsets.get('from', 0) / 1000.0
|
||||
end = offsets.get('to', 0) / 1000.0
|
||||
else:
|
||||
# 旧格式:[0, 5320]
|
||||
start = offsets[0] / 1000.0 if len(offsets) > 0 else 0
|
||||
end = offsets[1] / 1000.0 if len(offsets) > 1 else 0
|
||||
segments.append({
|
||||
'id': i,
|
||||
'start': start,
|
||||
'end': end,
|
||||
'text': item.get('text', '')
|
||||
})
|
||||
elif 'body' in data:
|
||||
# B站官方字幕格式
|
||||
for i, item in enumerate(data['body']):
|
||||
segments.append({
|
||||
'id': i,
|
||||
'start': item.get('from', 0),
|
||||
'end': item.get('to', 0),
|
||||
'text': item.get('content', '')
|
||||
})
|
||||
|
||||
text = ' '.join(s['text'] for s in segments)
|
||||
if segments:
|
||||
duration = segments[-1]['end']
|
||||
|
||||
output_files = {'json': str(json_path)}
|
||||
if srt_path.exists():
|
||||
output_files['srt'] = str(srt_path)
|
||||
if txt_path.exists():
|
||||
output_files['txt'] = str(txt_path)
|
||||
|
||||
return ASROutput(
|
||||
success=True,
|
||||
tool="existing",
|
||||
has_subtitle=True,
|
||||
output_files=output_files,
|
||||
segments=segments,
|
||||
text=text,
|
||||
duration_seconds=duration
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read existing subtitle file: {e}")
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
async def _try_official_subtitle(self, input_data: ASRInput) -> Optional[ASROutput]:
|
||||
"""尝试下载 B站官方字幕
|
||||
|
||||
Args:
|
||||
input_data: ASR 输入数据
|
||||
|
||||
Returns:
|
||||
如果找到字幕返回 ASROutput,否则返回 None
|
||||
|
||||
Note:
|
||||
无字幕返回 None(不是抛异常),让流程继续到 Whisper
|
||||
"""
|
||||
# 使用 content_dir 作为输出目录(而不是从 video_path 推断)
|
||||
output_dir = Path(input_data.content_dir)
|
||||
# 使用 content_id 作为基础文件名
|
||||
base_name = f"{input_data.platform}_{input_data.content_id}_subtitle"
|
||||
|
||||
# 输出文件路径
|
||||
txt_path = output_dir / f"{base_name}.txt"
|
||||
srt_path = output_dir / f"{base_name}.srt"
|
||||
json_path = output_dir / f"{base_name}.json"
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
try:
|
||||
# Step 1: 获取 CID
|
||||
bvid = input_data.content_id
|
||||
pagelist_url = f"https://api.bilibili.com/x/player/pagelist?bvid={bvid}"
|
||||
async with session.get(pagelist_url) as resp:
|
||||
pagelist_data = await resp.json()
|
||||
|
||||
pages = pagelist_data.get('data', [])
|
||||
if not pages:
|
||||
logger.info(f"No pagelist data for {bvid}")
|
||||
return None
|
||||
|
||||
cid = pages[0].get('cid')
|
||||
if not cid:
|
||||
logger.info(f"No cid found for {bvid}")
|
||||
return None
|
||||
|
||||
# Step 2: 获取字幕列表
|
||||
player_url = f"https://api.bilibili.com/x/player/v2?bvid={bvid}&cid={cid}"
|
||||
async with session.get(player_url) as resp:
|
||||
player_data = await resp.json()
|
||||
|
||||
subtitle_info = player_data.get('data', {}).get('subtitle', {})
|
||||
subtitles = subtitle_info.get('list', [])
|
||||
|
||||
if not subtitles:
|
||||
logger.info(f"No official subtitle for {bvid}")
|
||||
return None
|
||||
|
||||
# Step 3: 下载第一个字幕(优先 AI 生成的中文字幕)
|
||||
subtitle_url = None
|
||||
for sub in subtitles:
|
||||
if sub.get('lan', '').startswith('zh'):
|
||||
subtitle_url = sub.get('subtitle_url')
|
||||
break
|
||||
|
||||
if not subtitle_url and subtitles:
|
||||
subtitle_url = subtitles[0].get('subtitle_url')
|
||||
|
||||
if not subtitle_url:
|
||||
return None
|
||||
|
||||
# 补全 URL
|
||||
if subtitle_url.startswith('//'):
|
||||
subtitle_url = f"https:{subtitle_url}"
|
||||
|
||||
async with session.get(subtitle_url) as resp:
|
||||
subtitle_content = await resp.json()
|
||||
|
||||
# Step 4: 转换为输出格式
|
||||
segments = []
|
||||
text_parts = []
|
||||
body = subtitle_content.get('body', [])
|
||||
|
||||
for i, item in enumerate(body):
|
||||
start = item.get('from', 0)
|
||||
end = item.get('to', 0)
|
||||
content = item.get('content', '')
|
||||
|
||||
segments.append({
|
||||
'id': i,
|
||||
'start': start,
|
||||
'end': end,
|
||||
'text': content
|
||||
})
|
||||
text_parts.append(content)
|
||||
|
||||
full_text = ' '.join(text_parts)
|
||||
duration = segments[-1]['end'] if segments else 0
|
||||
|
||||
# Step 5: 写入文件
|
||||
# TXT 格式
|
||||
txt_path.write_text(full_text, encoding='utf-8')
|
||||
|
||||
# SRT 格式
|
||||
srt_content = self._convert_to_srt(segments)
|
||||
srt_path.write_text(srt_content, encoding='utf-8')
|
||||
|
||||
# JSON 格式
|
||||
json_data = {
|
||||
'success': True,
|
||||
'tool': 'subtitle',
|
||||
'segments': segments,
|
||||
'text': full_text,
|
||||
'duration_seconds': duration
|
||||
}
|
||||
json_path.write_text(json.dumps(json_data, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
return ASROutput(
|
||||
success=True,
|
||||
tool="subtitle",
|
||||
has_subtitle=True,
|
||||
output_files={
|
||||
'txt': str(txt_path),
|
||||
'srt': str(srt_path),
|
||||
'json': str(json_path)
|
||||
},
|
||||
segments=segments,
|
||||
text=full_text,
|
||||
duration_seconds=duration
|
||||
)
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
logger.warning(f"Failed to fetch subtitle: {e}")
|
||||
return None
|
||||
|
||||
def _convert_to_srt(self, segments: List[Dict]) -> str:
|
||||
"""将字幕分段转换为 SRT 格式
|
||||
|
||||
Args:
|
||||
segments: 字幕分段列表,每个包含 start, end, text
|
||||
|
||||
Returns:
|
||||
SRT 格式字符串
|
||||
"""
|
||||
lines = []
|
||||
for i, seg in enumerate(segments, 1):
|
||||
start_time = self._seconds_to_srt_time(seg['start'])
|
||||
end_time = self._seconds_to_srt_time(seg['end'])
|
||||
lines.append(f"{i}")
|
||||
lines.append(f"{start_time} --> {end_time}")
|
||||
lines.append(seg['text'])
|
||||
lines.append("") # 空行
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
def _seconds_to_srt_time(self, seconds: float) -> str:
|
||||
"""将秒数转换为 SRT 时间格式 (HH:MM:SS,mmm)"""
|
||||
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}"
|
||||
|
||||
async def _run_whisper(self, input_data: ASRInput) -> ASROutput:
|
||||
"""执行 Whisper 语音识别
|
||||
|
||||
根据 HF_TOKEN 配置自动选择普通模式或说话者分离模式。
|
||||
|
||||
Args:
|
||||
input_data: ASR 输入数据
|
||||
|
||||
Returns:
|
||||
ASR 处理结果
|
||||
"""
|
||||
hf_token = self.settings.hf_token.get_secret_value()
|
||||
if hf_token:
|
||||
return await self._run_whisper_with_diarization(input_data)
|
||||
else:
|
||||
return await self._run_whisper_basic(input_data)
|
||||
|
||||
async def _run_whisper_basic(self, input_data: ASRInput) -> ASROutput:
|
||||
"""执行普通 Whisper.cpp 语音识别
|
||||
|
||||
Args:
|
||||
input_data: ASR 输入数据
|
||||
|
||||
Returns:
|
||||
ASR 处理结果
|
||||
|
||||
Raises:
|
||||
RuntimeError: Whisper.cpp 路径不存在或执行失败,或 video_path 为空
|
||||
"""
|
||||
# 检查 video_path 是否有效
|
||||
if not input_data.video_path:
|
||||
raise RuntimeError(
|
||||
"video_path 为空,无法执行 Whisper 语音识别。"
|
||||
"请确保视频文件已下载(对于没有官方字幕的视频,需要先下载视频文件)。"
|
||||
)
|
||||
|
||||
video_path = Path(input_data.video_path)
|
||||
output_dir = Path(input_data.content_dir)
|
||||
base_name = f"{input_data.platform}_{input_data.content_id}_whisper"
|
||||
|
||||
# 获取 Whisper.cpp 路径
|
||||
try:
|
||||
whisper_path = get_whisper_cpp_path()
|
||||
except WhisperNotFoundError as e:
|
||||
raise RuntimeError(str(e))
|
||||
|
||||
# 模型路径
|
||||
model_path = os.path.join(whisper_path, 'models', 'ggml-base.bin')
|
||||
if not os.path.exists(model_path):
|
||||
raise RuntimeError(f"Whisper model not found: {model_path}")
|
||||
|
||||
# 转换音频格式
|
||||
audio_path = convert_to_wav(str(video_path))
|
||||
temp_audio_created = audio_path != str(video_path)
|
||||
|
||||
try:
|
||||
# 构建 Whisper 命令
|
||||
output_base = os.path.join(output_dir, base_name)
|
||||
cmd = [
|
||||
os.path.join(whisper_path, 'whisper-cli'),
|
||||
'-m', model_path,
|
||||
'-f', audio_path,
|
||||
'-of', output_base,
|
||||
'-l', 'zh',
|
||||
'--output-txt', '--output-srt', '--output-json',
|
||||
]
|
||||
|
||||
# 执行 Whisper
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Whisper failed: {result.stderr}")
|
||||
|
||||
# 解析输出
|
||||
return self._parse_whisper_output(output_dir, base_name)
|
||||
|
||||
finally:
|
||||
# 清理临时音频文件
|
||||
if temp_audio_created and os.path.exists(audio_path):
|
||||
try:
|
||||
os.remove(audio_path)
|
||||
except Exception:
|
||||
pass # 静默失败,不阻塞主流程
|
||||
|
||||
def _parse_whisper_output(self, output_dir: Path, base_name: str) -> ASROutput:
|
||||
"""解析 Whisper 输出文件
|
||||
|
||||
Args:
|
||||
output_dir: 输出目录
|
||||
base_name: 文件基础名
|
||||
|
||||
Returns:
|
||||
ASR 处理结果
|
||||
"""
|
||||
txt_path = output_dir / f"{base_name}.txt"
|
||||
srt_path = output_dir / f"{base_name}.srt"
|
||||
json_path = output_dir / f"{base_name}.json"
|
||||
|
||||
# 读取 JSON 输出
|
||||
segments = []
|
||||
text = ""
|
||||
duration = 0.0
|
||||
|
||||
if json_path.exists():
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
# Whisper.cpp JSON 格式
|
||||
if 'transcription' in data:
|
||||
for i, item in enumerate(data['transcription']):
|
||||
offsets = item.get('offsets', {})
|
||||
if isinstance(offsets, dict):
|
||||
# 新格式:{"from": 0, "to": 5320}
|
||||
start = offsets.get('from', 0) / 1000.0
|
||||
end = offsets.get('to', 0) / 1000.0
|
||||
else:
|
||||
# 旧格式:[0, 5320]
|
||||
start = offsets[0] / 1000.0 if len(offsets) > 0 else 0
|
||||
end = offsets[1] / 1000.0 if len(offsets) > 1 else 0
|
||||
segments.append({
|
||||
'id': i,
|
||||
'start': start,
|
||||
'end': end,
|
||||
'text': item.get('text', '')
|
||||
})
|
||||
text = ' '.join(s['text'] for s in segments)
|
||||
if segments:
|
||||
duration = segments[-1]['end']
|
||||
|
||||
# 读取文本
|
||||
if txt_path.exists():
|
||||
text = txt_path.read_text(encoding='utf-8').strip()
|
||||
|
||||
return ASROutput(
|
||||
success=True,
|
||||
tool="whisper",
|
||||
has_subtitle=False,
|
||||
output_files={
|
||||
'txt': str(txt_path),
|
||||
'srt': str(srt_path),
|
||||
'json': str(json_path)
|
||||
},
|
||||
segments=segments,
|
||||
text=text,
|
||||
duration_seconds=duration
|
||||
)
|
||||
|
||||
async def _run_whisper_with_diarization(self, input_data: ASRInput) -> ASROutput:
|
||||
"""执行带说话者分离的 Whisper
|
||||
|
||||
Args:
|
||||
input_data: ASR 输入数据
|
||||
|
||||
Returns:
|
||||
ASR 处理结果,包含说话者信息
|
||||
"""
|
||||
# 先运行普通 Whisper
|
||||
basic_result = await self._run_whisper_basic(input_data)
|
||||
|
||||
try:
|
||||
# 运行说话者分离
|
||||
diarization_result = run_diarization(input_data.video_path)
|
||||
|
||||
# 合并结果
|
||||
merged_segments = assign_speakers_to_segments(
|
||||
basic_result.segments,
|
||||
diarization_result
|
||||
)
|
||||
|
||||
# 提取说话者列表
|
||||
speakers = list(set(
|
||||
seg.get('speaker') for seg in merged_segments if seg.get('speaker')
|
||||
))
|
||||
|
||||
return ASROutput(
|
||||
success=True,
|
||||
tool="whisper-diarization",
|
||||
has_subtitle=False,
|
||||
output_files=basic_result.output_files,
|
||||
segments=merged_segments,
|
||||
text=basic_result.text,
|
||||
duration_seconds=basic_result.duration_seconds,
|
||||
speakers=sorted(speakers)
|
||||
)
|
||||
|
||||
except ImportError:
|
||||
logger.warning("pyannote.audio not available, falling back to basic whisper")
|
||||
return basic_result
|
||||
except Exception as e:
|
||||
logger.warning(f"Diarization failed: {e}, falling back to basic whisper")
|
||||
return basic_result
|
||||
|
||||
|
||||
def run_diarization(audio_path: str, num_speakers: Optional[int] = None) -> Dict[str, List[Dict]]:
|
||||
"""运行 pyannote.audio 说话者分离
|
||||
|
||||
Args:
|
||||
audio_path: 音频文件路径
|
||||
num_speakers: 预期说话者数量(可选)
|
||||
|
||||
Returns:
|
||||
说话者时间线字典 {speaker_name: [{start, end}, ...]}
|
||||
|
||||
Raises:
|
||||
ImportError: 如果 pyannote.audio 未安装
|
||||
RuntimeError: 如果 HF_TOKEN 未配置
|
||||
"""
|
||||
hf_token = os.environ.get('HF_TOKEN')
|
||||
if not hf_token:
|
||||
raise RuntimeError("需要设置 HF_TOKEN 环境变量以使用说话者分离功能")
|
||||
|
||||
from huggingface_hub import login
|
||||
from pyannote.audio import Pipeline
|
||||
import torch
|
||||
|
||||
login(token=hf_token)
|
||||
|
||||
pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1")
|
||||
|
||||
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
pipeline.to(device)
|
||||
|
||||
diarization = pipeline(audio_path, num_speakers=num_speakers)
|
||||
|
||||
# 解析结果
|
||||
speakers = {}
|
||||
for turn, _, speaker in diarization.itertracks(yield_label=True):
|
||||
if speaker not in speakers:
|
||||
speakers[speaker] = []
|
||||
speakers[speaker].append({
|
||||
"start": round(turn.start, 2),
|
||||
"end": round(turn.end, 2)
|
||||
})
|
||||
|
||||
return speakers
|
||||
|
||||
|
||||
def assign_speakers_to_segments(
|
||||
segments: List[Dict],
|
||||
diarization: Dict[str, List[Dict]]
|
||||
) -> List[Dict]:
|
||||
"""为每个段落分配说话者
|
||||
|
||||
根据时间戳匹配说话者分离结果到 Whisper 段落。
|
||||
|
||||
Args:
|
||||
segments: Whisper 段落列表
|
||||
diarization: 说话者分离结果
|
||||
|
||||
Returns:
|
||||
带有 speaker 字段的段落列表
|
||||
"""
|
||||
result = []
|
||||
|
||||
for seg in segments:
|
||||
seg_start = seg.get('start', 0)
|
||||
seg_end = seg.get('end', 0)
|
||||
seg_mid = (seg_start + seg_end) / 2
|
||||
|
||||
# 找到包含中点时间的说话者
|
||||
assigned_speaker = None
|
||||
for speaker, turns in diarization.items():
|
||||
for turn in turns:
|
||||
if turn['start'] <= seg_mid <= turn['end']:
|
||||
assigned_speaker = speaker
|
||||
break
|
||||
if assigned_speaker:
|
||||
break
|
||||
|
||||
new_seg = dict(seg)
|
||||
new_seg['speaker'] = assigned_speaker
|
||||
result.append(new_seg)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,55 @@
|
||||
"""BaseProcessor - Abstract base class for all pipeline processors
|
||||
|
||||
Provides a unified interface that all processors (parse, download, asr, analyze)
|
||||
must follow, enabling consistent state management and error handling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from kb_workflow.pipeline.config import get_settings
|
||||
from kb_workflow.pipeline.schemas import StepInput, StepOutput
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from kb_workflow.pipeline.state import WorkflowStateManager
|
||||
|
||||
|
||||
class BaseProcessor(ABC):
|
||||
"""Abstract base class for all pipeline processors.
|
||||
|
||||
All processors (ParseProcessor, DownloadProcessor, ASRProcessor, AnalyzeProcessor)
|
||||
must inherit from this class and implement the process() method.
|
||||
|
||||
Attributes:
|
||||
STEP_NAME: Class attribute identifying the processor step name.
|
||||
settings: KBSettings instance from get_settings().
|
||||
state_manager: Optional WorkflowStateManager for state recording.
|
||||
"""
|
||||
|
||||
STEP_NAME: str
|
||||
|
||||
def __init__(self, state_manager: Optional[WorkflowStateManager] = None):
|
||||
"""Initialize the processor.
|
||||
|
||||
Args:
|
||||
state_manager: Optional WorkflowStateManager for recording step state.
|
||||
"""
|
||||
self.settings = get_settings()
|
||||
self.state_manager = state_manager
|
||||
|
||||
@abstractmethod
|
||||
async def process(self, input_data: StepInput) -> StepOutput:
|
||||
"""Process the input data and return the result.
|
||||
|
||||
Args:
|
||||
input_data: Input data for the processor step.
|
||||
|
||||
Returns:
|
||||
StepOutput with processing results.
|
||||
|
||||
Raises:
|
||||
Exception: If processing fails.
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,338 @@
|
||||
"""DownloadProcessor - Content download processor
|
||||
|
||||
Handles metadata extraction and content downloading for all platforms.
|
||||
Works with PlatformAdapter implementations (BilibiliAdapter, ZhihuAdapter).
|
||||
|
||||
Purpose: Unify metadata and content fetching into a single processor
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, TYPE_CHECKING, Dict, Any
|
||||
|
||||
from kb_workflow.pipeline.schemas import DownloadInput, DownloadOutput
|
||||
from kb_workflow.pipeline.processors.base import BaseProcessor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from kb_workflow.pipeline.state import WorkflowStateManager
|
||||
from kb_workflow.pipeline.platforms.base import PlatformAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DownloadProcessor(BaseProcessor):
|
||||
"""Content download processor.
|
||||
|
||||
Fetches metadata and content from platforms using PlatformAdapter.
|
||||
Supports video, article, answer, and zhuanlan content types.
|
||||
|
||||
Attributes:
|
||||
STEP_NAME: "download" - identifies this processor step
|
||||
"""
|
||||
|
||||
STEP_NAME = "download"
|
||||
|
||||
async def process(self, input_data: DownloadInput) -> DownloadOutput:
|
||||
"""Process the download request.
|
||||
|
||||
Args:
|
||||
input_data: DownloadInput with content_id, platform, content_dir, content_type
|
||||
|
||||
Returns:
|
||||
DownloadOutput with metadata_file, content_file, title, author, etc.
|
||||
"""
|
||||
content_dir = Path(input_data.content_dir)
|
||||
content_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Check if step already completed (idempotency)
|
||||
if self.state_manager:
|
||||
state = self.state_manager.load()
|
||||
if state and state.is_step_completed(self.STEP_NAME):
|
||||
logger.info(f"Step {self.STEP_NAME} already completed, skipping")
|
||||
# Return a success output based on existing files
|
||||
return self._get_existing_output(input_data, content_dir)
|
||||
|
||||
# Get appropriate adapter
|
||||
try:
|
||||
adapter = self._get_adapter(input_data.platform)
|
||||
except ValueError as e:
|
||||
return self._record_failure(str(e))
|
||||
|
||||
try:
|
||||
# Fetch metadata (pass source_url for zhihu answers)
|
||||
if input_data.platform == "zhihu":
|
||||
metadata = await adapter.fetch_metadata(
|
||||
input_data.content_id,
|
||||
input_data.content_type,
|
||||
source_url=input_data.source_url
|
||||
)
|
||||
else:
|
||||
metadata = await adapter.fetch_metadata(
|
||||
input_data.content_id,
|
||||
input_data.content_type
|
||||
)
|
||||
|
||||
# Save metadata
|
||||
metadata_filename = adapter.get_output_filename(
|
||||
input_data.content_id,
|
||||
input_data.content_type,
|
||||
"metadata.json"
|
||||
)
|
||||
metadata_path = content_dir / metadata_filename
|
||||
self._save_metadata(metadata, metadata_path)
|
||||
|
||||
# Extract title and author from metadata
|
||||
title = self._extract_title(metadata, input_data.platform)
|
||||
author = self._extract_author(metadata, input_data.platform)
|
||||
duration = self._extract_duration(metadata, input_data.content_type)
|
||||
|
||||
# Fetch content (for text-based content)
|
||||
content_file = ""
|
||||
if input_data.content_type in ["article", "answer", "zhuanlan"]:
|
||||
if input_data.platform == "zhihu":
|
||||
content = await adapter.fetch_content(
|
||||
input_data.content_id,
|
||||
input_data.content_type,
|
||||
source_url=input_data.source_url
|
||||
)
|
||||
else:
|
||||
content = await adapter.fetch_content(
|
||||
input_data.content_id,
|
||||
input_data.content_type
|
||||
)
|
||||
content_filename = adapter.get_output_filename(
|
||||
input_data.content_id,
|
||||
input_data.content_type,
|
||||
"content.md"
|
||||
)
|
||||
content_path = content_dir / content_filename
|
||||
self._save_content(content, content_path)
|
||||
content_file = str(content_path)
|
||||
|
||||
# Download video for ASR (Whisper needs video file)
|
||||
if input_data.content_type == "video" and input_data.platform == "bilibili":
|
||||
download_result = await adapter.download_video(
|
||||
input_data.content_id,
|
||||
content_dir
|
||||
)
|
||||
if download_result["success"]:
|
||||
content_file = download_result["filepath"]
|
||||
logger.info(f"Video downloaded for ASR: {content_file}")
|
||||
else:
|
||||
logger.warning(f"Video download failed: {download_result['message']}")
|
||||
|
||||
# Record success
|
||||
result = DownloadOutput(
|
||||
success=True,
|
||||
metadata_file=str(metadata_path),
|
||||
content_file=content_file,
|
||||
title=title,
|
||||
author=author,
|
||||
duration=duration
|
||||
)
|
||||
|
||||
if self.state_manager:
|
||||
state = self.state_manager.load()
|
||||
if state:
|
||||
summary = f"title={title}, type={input_data.content_type}"
|
||||
state.mark_step_completed(self.STEP_NAME, summary)
|
||||
self.state_manager.save(state)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Download failed: {e}")
|
||||
return self._record_failure(str(e))
|
||||
|
||||
def _get_adapter(self, platform: str) -> PlatformAdapter:
|
||||
"""Get the appropriate PlatformAdapter for the platform.
|
||||
|
||||
Args:
|
||||
platform: Platform name ("bilibili" or "zhihu")
|
||||
|
||||
Returns:
|
||||
PlatformAdapter instance
|
||||
|
||||
Raises:
|
||||
ValueError: If platform is not supported
|
||||
"""
|
||||
if platform == "bilibili":
|
||||
from kb_workflow.pipeline.platforms.bilibili import BilibiliAdapter
|
||||
return BilibiliAdapter(state_manager=self.state_manager)
|
||||
elif platform == "zhihu":
|
||||
from kb_workflow.pipeline.platforms.zhihu import ZhihuAdapter
|
||||
return ZhihuAdapter()
|
||||
else:
|
||||
raise ValueError(f"Unsupported platform: {platform}")
|
||||
|
||||
def _save_metadata(self, metadata: Dict[str, Any], path: Path) -> None:
|
||||
"""Save metadata to JSON file.
|
||||
|
||||
Args:
|
||||
metadata: Metadata dictionary
|
||||
path: Output file path
|
||||
"""
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
||||
logger.info(f"Saved metadata to {path}")
|
||||
|
||||
def _save_content(self, content: str, path: Path) -> None:
|
||||
"""Save content to Markdown file.
|
||||
|
||||
Args:
|
||||
content: Markdown content string
|
||||
path: Output file path
|
||||
"""
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
logger.info(f"Saved content to {path}")
|
||||
|
||||
def _extract_title(self, metadata: Dict[str, Any], platform: str) -> str:
|
||||
"""Extract title from metadata.
|
||||
|
||||
Args:
|
||||
metadata: Metadata dictionary
|
||||
platform: Platform name
|
||||
|
||||
Returns:
|
||||
Title string
|
||||
"""
|
||||
if platform == "bilibili":
|
||||
# Bilibili metadata structure: {metadata: {title: ...}, ...}
|
||||
if "metadata" in metadata:
|
||||
return metadata["metadata"].get("title", "")
|
||||
return metadata.get("title", "")
|
||||
else:
|
||||
return metadata.get("title", "")
|
||||
|
||||
def _extract_author(self, metadata: Dict[str, Any], platform: str) -> str:
|
||||
"""Extract author from metadata.
|
||||
|
||||
Args:
|
||||
metadata: Metadata dictionary
|
||||
platform: Platform name
|
||||
|
||||
Returns:
|
||||
Author string (author name, not mid)
|
||||
"""
|
||||
if platform == "bilibili":
|
||||
# Bilibili metadata structure: {metadata: {author: ..., mid: ...}, ...}
|
||||
if "metadata" in metadata:
|
||||
return str(metadata["metadata"].get("author", ""))
|
||||
return str(metadata.get("author", ""))
|
||||
else:
|
||||
return metadata.get("author", "")
|
||||
|
||||
def _extract_duration(self, metadata: Dict[str, Any], content_type: str) -> Optional[int]:
|
||||
"""Extract duration from metadata for video content.
|
||||
|
||||
Args:
|
||||
metadata: Metadata dictionary
|
||||
content_type: Content type
|
||||
|
||||
Returns:
|
||||
Duration in seconds for videos, None otherwise
|
||||
"""
|
||||
if content_type != "video":
|
||||
return None
|
||||
|
||||
# Bilibili video duration
|
||||
if "metadata" in metadata and "duration" in metadata["metadata"]:
|
||||
duration = metadata["metadata"]["duration"]
|
||||
# Handle formatted duration string (e.g., "12:34" or "01:23:45")
|
||||
if isinstance(duration, str):
|
||||
if not duration.strip():
|
||||
return None
|
||||
parts = duration.split(':')
|
||||
try:
|
||||
if len(parts) == 2:
|
||||
return int(parts[0]) * 60 + int(parts[1])
|
||||
elif len(parts) == 3:
|
||||
return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
elif isinstance(duration, (int, float)):
|
||||
return int(duration)
|
||||
|
||||
return None
|
||||
|
||||
def _record_failure(self, error_message: str) -> DownloadOutput:
|
||||
"""Record failure to state manager and return failure output.
|
||||
|
||||
Args:
|
||||
error_message: Error message
|
||||
|
||||
Returns:
|
||||
DownloadOutput with success=False
|
||||
"""
|
||||
if self.state_manager:
|
||||
state = self.state_manager.load()
|
||||
if state:
|
||||
state.mark_step_failed(self.STEP_NAME, error_message)
|
||||
self.state_manager.save(state)
|
||||
|
||||
return DownloadOutput(
|
||||
success=False,
|
||||
error_message=error_message,
|
||||
metadata_file="",
|
||||
content_file="",
|
||||
title="",
|
||||
author=""
|
||||
)
|
||||
|
||||
def _get_existing_output(
|
||||
self,
|
||||
input_data: DownloadInput,
|
||||
content_dir: Path
|
||||
) -> DownloadOutput:
|
||||
"""Get output from existing files when step was already completed.
|
||||
|
||||
Args:
|
||||
input_data: DownloadInput
|
||||
content_dir: Content directory path
|
||||
|
||||
Returns:
|
||||
DownloadOutput based on existing files
|
||||
"""
|
||||
adapter = self._get_adapter(input_data.platform)
|
||||
|
||||
metadata_filename = adapter.get_output_filename(
|
||||
input_data.content_id,
|
||||
input_data.content_type,
|
||||
"metadata.json"
|
||||
)
|
||||
metadata_path = content_dir / metadata_filename
|
||||
|
||||
# Try to read metadata for title/author
|
||||
title = ""
|
||||
author = ""
|
||||
duration = None
|
||||
if metadata_path.exists():
|
||||
try:
|
||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||
metadata = json.load(f)
|
||||
title = self._extract_title(metadata, input_data.platform)
|
||||
author = self._extract_author(metadata, input_data.platform)
|
||||
duration = self._extract_duration(metadata, input_data.content_type)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
content_filename = adapter.get_output_filename(
|
||||
input_data.content_id,
|
||||
input_data.content_type,
|
||||
"content.md"
|
||||
)
|
||||
content_path = content_dir / content_filename
|
||||
content_file = str(content_path) if content_path.exists() else ""
|
||||
|
||||
return DownloadOutput(
|
||||
success=True,
|
||||
metadata_file=str(metadata_path),
|
||||
content_file=content_file,
|
||||
title=title,
|
||||
author=author,
|
||||
duration=duration
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""ParseProcessor - URL parsing processor
|
||||
|
||||
Parses URLs from various platforms (Bilibili, Zhihu) and extracts
|
||||
platform, content type, and content ID information.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from kb_workflow.pipeline.config import get_settings
|
||||
from kb_workflow.pipeline.link_parser import LinkParser
|
||||
from kb_workflow.pipeline.schemas import ParseInput, ParseOutput
|
||||
from kb_workflow.pipeline.processors.base import BaseProcessor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from kb_workflow.pipeline.state import WorkflowStateManager
|
||||
|
||||
|
||||
class ParseProcessor(BaseProcessor):
|
||||
"""URL parsing processor.
|
||||
|
||||
Parses URLs from Bilibili and Zhihu platforms, extracting
|
||||
platform, content type, and content ID.
|
||||
|
||||
Attributes:
|
||||
STEP_NAME: "parse" - identifies this processor step
|
||||
"""
|
||||
|
||||
STEP_NAME = "parse"
|
||||
|
||||
async def process(self, input_data: ParseInput) -> ParseOutput:
|
||||
"""Parse the URL and extract platform information.
|
||||
|
||||
Args:
|
||||
input_data: ParseInput with URL to parse
|
||||
|
||||
Returns:
|
||||
ParseOutput with parsed information or error message
|
||||
"""
|
||||
parser = LinkParser()
|
||||
parsed = parser.parse(input_data.url)
|
||||
|
||||
if parsed is None:
|
||||
# Unknown URL format
|
||||
result = ParseOutput(
|
||||
success=False,
|
||||
error_message="Unsupported URL format",
|
||||
platform="",
|
||||
content_type="",
|
||||
content_id="",
|
||||
resolved_url="",
|
||||
original_url=input_data.url
|
||||
)
|
||||
|
||||
# Record failure state
|
||||
if self.state_manager:
|
||||
state = self.state_manager.load()
|
||||
if state:
|
||||
state.mark_step_failed(self.STEP_NAME, result.error_message)
|
||||
self.state_manager.save(state)
|
||||
|
||||
return result
|
||||
|
||||
# Successful parse
|
||||
result = ParseOutput(
|
||||
success=True,
|
||||
platform=parsed.platform,
|
||||
content_type=parsed.content_type,
|
||||
content_id=parsed.content_id,
|
||||
resolved_url=parsed.resolved_url,
|
||||
original_url=parsed.original_url
|
||||
)
|
||||
|
||||
# Record success state
|
||||
if self.state_manager:
|
||||
state = self.state_manager.load()
|
||||
if state:
|
||||
summary = f"platform={parsed.platform}, type={parsed.content_type}, id={parsed.content_id}"
|
||||
state.mark_step_completed(self.STEP_NAME, summary)
|
||||
self.state_manager.save(state)
|
||||
|
||||
return result
|
||||
Reference in New Issue
Block a user