chore(06-02): remove original kb_pipeline directory
All code has been moved to kb_workflow.pipeline with updated imports. 278 tests pass (14 pre-existing mock-related failures unrelated to refactoring). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,64 +0,0 @@
|
|||||||
"""KB Pipeline - 工作流管道基础设施
|
|
||||||
|
|
||||||
提供内容处理工作流的核心 Schema 和工具。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .schemas import (
|
|
||||||
StepInput,
|
|
||||||
StepOutput,
|
|
||||||
StepResult,
|
|
||||||
StepStatus,
|
|
||||||
StepRecord,
|
|
||||||
WorkflowState,
|
|
||||||
StepDefinition,
|
|
||||||
)
|
|
||||||
from .state import WorkflowStateManager
|
|
||||||
from .errors import (
|
|
||||||
KBError,
|
|
||||||
NetworkError,
|
|
||||||
ValidationError,
|
|
||||||
SystemError,
|
|
||||||
AuthError,
|
|
||||||
ErrorType,
|
|
||||||
)
|
|
||||||
from .config import KBSettings, get_settings
|
|
||||||
from .retry import retry_on_network_error
|
|
||||||
from .link_parser import LinkParser, ParsedLink
|
|
||||||
from .idempotency import IdempotencyChecker
|
|
||||||
from .platforms import PlatformAdapter, BilibiliAdapter, ZhihuAdapter
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
# Schemas
|
|
||||||
"StepInput",
|
|
||||||
"StepOutput",
|
|
||||||
"StepResult",
|
|
||||||
"StepStatus",
|
|
||||||
"StepRecord",
|
|
||||||
"WorkflowState",
|
|
||||||
"StepDefinition",
|
|
||||||
# State
|
|
||||||
"WorkflowStateManager",
|
|
||||||
# Errors
|
|
||||||
"KBError",
|
|
||||||
"NetworkError",
|
|
||||||
"ValidationError",
|
|
||||||
"SystemError",
|
|
||||||
"AuthError",
|
|
||||||
"ErrorType",
|
|
||||||
# Config
|
|
||||||
"KBSettings",
|
|
||||||
"get_settings",
|
|
||||||
# Retry
|
|
||||||
"retry_on_network_error",
|
|
||||||
# Link Parser
|
|
||||||
"LinkParser",
|
|
||||||
"ParsedLink",
|
|
||||||
# Idempotency
|
|
||||||
"IdempotencyChecker",
|
|
||||||
# Platform Adapters
|
|
||||||
"PlatformAdapter",
|
|
||||||
"BilibiliAdapter",
|
|
||||||
"ZhihuAdapter",
|
|
||||||
]
|
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
"""Configuration management for KB Pipeline
|
|
||||||
|
|
||||||
Provides centralized configuration using pydantic-settings.
|
|
||||||
Configuration is loaded from ~/.kb_env file and environment variables.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from functools import lru_cache
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic import Field, SecretStr
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class KBSettings(BaseSettings):
|
|
||||||
"""KB 工作流配置
|
|
||||||
|
|
||||||
使用 pydantic-settings 从环境变量和 ~/.kb_env 文件加载配置。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
base_path: 知识库根目录(必需)
|
|
||||||
archive_path: 归档目录(必需)
|
|
||||||
git_root: Git 仓库根目录(可选)
|
|
||||||
git_user: Git 用户名(可选)
|
|
||||||
git_email: Git 邮箱(可选)
|
|
||||||
hf_token: HuggingFace Token(敏感信息)
|
|
||||||
zhihu_cookie: 知乎 Cookie(敏感信息)
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
|
||||||
env_file=str(Path.home() / ".kb_env"),
|
|
||||||
env_file_encoding="utf-8",
|
|
||||||
env_prefix="KB_",
|
|
||||||
extra="ignore",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 必需配置
|
|
||||||
base_path: str = Field(..., description="知识库根目录")
|
|
||||||
archive_path: str = Field(..., description="归档目录")
|
|
||||||
intermediate_path: str = Field(
|
|
||||||
default_factory=lambda: str(Path.home() / "work" / "intermediate" / "analysis"),
|
|
||||||
description="中间文件存储目录"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 可选配置
|
|
||||||
git_root: str = Field(default="", description="Git 仓库根目录")
|
|
||||||
git_user: str = Field(default="", description="Git 用户名")
|
|
||||||
git_email: str = Field(default="", description="Git 邮箱")
|
|
||||||
|
|
||||||
# 敏感信息(从环境变量读取)
|
|
||||||
hf_token: SecretStr = Field(
|
|
||||||
default=SecretStr(""), description="HuggingFace Token"
|
|
||||||
)
|
|
||||||
zhihu_cookie: SecretStr = Field(
|
|
||||||
default=SecretStr(""), description="知乎 Cookie"
|
|
||||||
)
|
|
||||||
bilibili_cookie: SecretStr = Field(
|
|
||||||
default=SecretStr(""), description="B站 Cookie (for video download)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def get_settings() -> KBSettings:
|
|
||||||
"""获取配置单例
|
|
||||||
|
|
||||||
使用 lru_cache 确保配置只加载一次。
|
|
||||||
如需重新加载配置,调用 get_settings.cache_clear()。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
KBSettings 实例
|
|
||||||
"""
|
|
||||||
return KBSettings()
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
"""Structured error handling for KB Pipeline
|
|
||||||
|
|
||||||
Provides structured error types with context information for debugging.
|
|
||||||
All errors include step_name, input_summary, error_type, message, and suggestion.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Optional, List, Any
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorType(str, Enum):
|
|
||||||
"""错误类型分类"""
|
|
||||||
NETWORK = "network"
|
|
||||||
VALIDATION = "validation"
|
|
||||||
SYSTEM = "system"
|
|
||||||
AUTH = "auth"
|
|
||||||
|
|
||||||
|
|
||||||
class KBError(Exception):
|
|
||||||
"""KB 工作流基础错误
|
|
||||||
|
|
||||||
所有工作流错误的基类,包含丰富的上下文信息便于调试。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
message: 错误信息
|
|
||||||
error_type: 错误类型
|
|
||||||
step_name: 发生错误的步骤名称
|
|
||||||
input_summary: 输入数据摘要
|
|
||||||
suggestion: 可操作的建议
|
|
||||||
cause: 原始异常(可选)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
message: str,
|
|
||||||
error_type: ErrorType,
|
|
||||||
step_name: str,
|
|
||||||
input_summary: str,
|
|
||||||
suggestion: str,
|
|
||||||
cause: Optional[Exception] = None
|
|
||||||
):
|
|
||||||
super().__init__(message)
|
|
||||||
self.message = message
|
|
||||||
self.error_type = error_type
|
|
||||||
self.step_name = step_name
|
|
||||||
self.input_summary = input_summary
|
|
||||||
self.suggestion = suggestion
|
|
||||||
self.cause = cause
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
|
||||||
"""将错误转换为字典格式用于日志和状态记录
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
包含所有错误上下文信息的字典
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
"error_type": self.error_type.value,
|
|
||||||
"step_name": self.step_name,
|
|
||||||
"input_summary": self.input_summary,
|
|
||||||
"message": self.message,
|
|
||||||
"suggestion": self.suggestion
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class NetworkError(KBError):
|
|
||||||
"""网络错误 (403/404/500/超时)
|
|
||||||
|
|
||||||
用于表示网络请求失败,包括 HTTP 状态码错误和连接超时。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
status_code: HTTP 状态码(可选)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
step_name: str,
|
|
||||||
input_summary: str,
|
|
||||||
status_code: Optional[int] = None,
|
|
||||||
cause: Optional[Exception] = None
|
|
||||||
):
|
|
||||||
if status_code:
|
|
||||||
suggestion = f"检查网络连接,状态码: {status_code}"
|
|
||||||
else:
|
|
||||||
suggestion = "检查网络连接"
|
|
||||||
super().__init__(
|
|
||||||
message=f"网络请求失败: {status_code or 'unknown'}",
|
|
||||||
error_type=ErrorType.NETWORK,
|
|
||||||
step_name=step_name,
|
|
||||||
input_summary=input_summary,
|
|
||||||
suggestion=suggestion,
|
|
||||||
cause=cause
|
|
||||||
)
|
|
||||||
self.status_code = status_code
|
|
||||||
|
|
||||||
|
|
||||||
class ValidationError(KBError):
|
|
||||||
"""验证错误 (Schema 验证失败)
|
|
||||||
|
|
||||||
用于表示数据验证失败,包含详细的验证错误列表。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
validation_errors: 验证错误列表
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
step_name: str,
|
|
||||||
input_summary: str,
|
|
||||||
validation_errors: Optional[List[str]] = None,
|
|
||||||
cause: Optional[Exception] = None
|
|
||||||
):
|
|
||||||
super().__init__(
|
|
||||||
message="Schema 验证失败",
|
|
||||||
error_type=ErrorType.VALIDATION,
|
|
||||||
step_name=step_name,
|
|
||||||
input_summary=input_summary,
|
|
||||||
suggestion="检查输入数据格式是否符合要求",
|
|
||||||
cause=cause
|
|
||||||
)
|
|
||||||
self.validation_errors = validation_errors or []
|
|
||||||
|
|
||||||
|
|
||||||
class SystemError(KBError):
|
|
||||||
"""系统错误 (文件系统/内存/依赖缺失)
|
|
||||||
|
|
||||||
用于表示系统级错误,如文件不存在、内存不足、依赖缺失等。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
system_message: 系统错误消息
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
step_name: str,
|
|
||||||
input_summary: str,
|
|
||||||
system_message: Optional[str] = None,
|
|
||||||
cause: Optional[Exception] = None
|
|
||||||
):
|
|
||||||
super().__init__(
|
|
||||||
message=f"系统错误: {system_message or 'unknown'}",
|
|
||||||
error_type=ErrorType.SYSTEM,
|
|
||||||
step_name=step_name,
|
|
||||||
input_summary=input_summary,
|
|
||||||
suggestion="检查系统资源和依赖是否满足要求",
|
|
||||||
cause=cause
|
|
||||||
)
|
|
||||||
self.system_message = system_message
|
|
||||||
|
|
||||||
|
|
||||||
class AuthError(KBError):
|
|
||||||
"""认证/配额错误 (API配额/Cookie过期/Token失效)
|
|
||||||
|
|
||||||
用于表示认证失败或 API 配额超限。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
auth_type: 认证类型(如 API Token, Cookie 等)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
step_name: str,
|
|
||||||
input_summary: str,
|
|
||||||
auth_type: Optional[str] = None,
|
|
||||||
cause: Optional[Exception] = None
|
|
||||||
):
|
|
||||||
super().__init__(
|
|
||||||
message=f"认证失败: {auth_type or 'unknown'}",
|
|
||||||
error_type=ErrorType.AUTH,
|
|
||||||
step_name=step_name,
|
|
||||||
input_summary=input_summary,
|
|
||||||
suggestion="检查 API Token 或 Cookie 是否有效",
|
|
||||||
cause=cause
|
|
||||||
)
|
|
||||||
self.auth_type = auth_type
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"""Idempotency Checker module
|
|
||||||
|
|
||||||
Provides file existence checking to avoid reprocessing content.
|
|
||||||
Checks for metadata JSON and content Markdown files.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Tuple, List
|
|
||||||
|
|
||||||
|
|
||||||
class IdempotencyChecker:
|
|
||||||
"""Checks if content has already been processed
|
|
||||||
|
|
||||||
Determines if the output files for a given content already exist,
|
|
||||||
enabling idempotent processing to avoid duplicate work.
|
|
||||||
|
|
||||||
File naming convention:
|
|
||||||
- Metadata: {platform}_{content_id}_metadata.json
|
|
||||||
- Content: {platform}_{content_id}_content.md
|
|
||||||
"""
|
|
||||||
|
|
||||||
def check_exists(
|
|
||||||
self,
|
|
||||||
output_dir: Path,
|
|
||||||
content_id: str,
|
|
||||||
platform: str
|
|
||||||
) -> Tuple[bool, List[Path]]:
|
|
||||||
"""Check if output files already exist for this content
|
|
||||||
|
|
||||||
Args:
|
|
||||||
output_dir: Directory where output files are stored
|
|
||||||
content_id: Unique content identifier (e.g., BV1GJ411x7Qw)
|
|
||||||
platform: Platform name (e.g., bilibili, zhihu)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (all_exist, existing_files):
|
|
||||||
- all_exist: True if both metadata and content files exist
|
|
||||||
- existing_files: List of Path objects for existing files
|
|
||||||
"""
|
|
||||||
existing_files: List[Path] = []
|
|
||||||
|
|
||||||
if not content_id or not platform:
|
|
||||||
return (False, existing_files)
|
|
||||||
|
|
||||||
# Define expected file paths
|
|
||||||
metadata_file = output_dir / f"{platform}_{content_id}_metadata.json"
|
|
||||||
content_file = output_dir / f"{platform}_{content_id}_content.md"
|
|
||||||
|
|
||||||
# Check if files exist
|
|
||||||
if metadata_file.exists():
|
|
||||||
existing_files.append(metadata_file)
|
|
||||||
|
|
||||||
if content_file.exists():
|
|
||||||
existing_files.append(content_file)
|
|
||||||
|
|
||||||
# Return True only if both files exist
|
|
||||||
all_exist = len(existing_files) == 2
|
|
||||||
|
|
||||||
return (all_exist, existing_files)
|
|
||||||
|
|
||||||
def get_expected_files(
|
|
||||||
self,
|
|
||||||
output_dir: Path,
|
|
||||||
content_id: str,
|
|
||||||
platform: str
|
|
||||||
) -> Tuple[Path, Path]:
|
|
||||||
"""Get the expected file paths for a content
|
|
||||||
|
|
||||||
Args:
|
|
||||||
output_dir: Directory where output files are stored
|
|
||||||
content_id: Unique content identifier
|
|
||||||
platform: Platform name
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (metadata_path, content_path)
|
|
||||||
"""
|
|
||||||
metadata_path = output_dir / f"{platform}_{content_id}_metadata.json"
|
|
||||||
content_path = output_dir / f"{platform}_{content_id}_content.md"
|
|
||||||
|
|
||||||
return (metadata_path, content_path)
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
"""Link Parser module
|
|
||||||
|
|
||||||
Provides unified URL parsing for various content platforms.
|
|
||||||
Supports Bilibili (video, article) and Zhihu (answer, zhuanlan) URLs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from kb_pipeline.retry import retry_on_network_error
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ParsedLink:
|
|
||||||
"""Represents a parsed link with platform and content information
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
platform: Platform name (bilibili, zhihu)
|
|
||||||
content_type: Content type (video, article, answer, zhuanlan)
|
|
||||||
content_id: Unique content identifier
|
|
||||||
original_url: Original URL provided by user
|
|
||||||
resolved_url: Resolved URL after short link expansion (defaults to original_url)
|
|
||||||
"""
|
|
||||||
platform: str
|
|
||||||
content_type: str
|
|
||||||
content_id: str
|
|
||||||
original_url: str
|
|
||||||
resolved_url: str = field(default="")
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
"""Set resolved_url to original_url if not provided"""
|
|
||||||
if not self.resolved_url:
|
|
||||||
self.resolved_url = self.original_url
|
|
||||||
|
|
||||||
|
|
||||||
class LinkParser:
|
|
||||||
"""Unified URL parser for multiple platforms
|
|
||||||
|
|
||||||
Parses URLs from Bilibili and Zhihu, extracting platform, content type,
|
|
||||||
and content ID. Supports short link resolution for b23.tv URLs.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Bilibili patterns
|
|
||||||
BILIBILI_VIDEO_PATTERN = re.compile(
|
|
||||||
r'https?://(?:www\.)?bilibili\.com/video/(BV[a-zA-Z0-9]+)',
|
|
||||||
re.IGNORECASE
|
|
||||||
)
|
|
||||||
BILIBILI_ARTICLE_PATTERN = re.compile(
|
|
||||||
r'https?://(?:www\.)?bilibili\.com/(?:read|article)/(cv[a-zA-Z0-9]+)',
|
|
||||||
re.IGNORECASE
|
|
||||||
)
|
|
||||||
|
|
||||||
# Zhihu patterns
|
|
||||||
ZHIHU_ANSWER_PATTERN = re.compile(
|
|
||||||
r'https?://(?:www\.)?zhihu\.com/question/\d+/answer/(\d+)',
|
|
||||||
re.IGNORECASE
|
|
||||||
)
|
|
||||||
ZHIHU_ZHUANLAN_PATTERN = re.compile(
|
|
||||||
r'https?://zhuanlan\.zhihu\.com/p/(\d+)',
|
|
||||||
re.IGNORECASE
|
|
||||||
)
|
|
||||||
|
|
||||||
# Short link patterns
|
|
||||||
B23_TV_PATTERN = re.compile(
|
|
||||||
r'https?://(?:www\.)?b23\.tv/([a-zA-Z0-9]+)',
|
|
||||||
re.IGNORECASE
|
|
||||||
)
|
|
||||||
|
|
||||||
# Timeout for short link resolution
|
|
||||||
SHORT_LINK_TIMEOUT = 10
|
|
||||||
|
|
||||||
@retry_on_network_error
|
|
||||||
def _resolve_shortlink(self, url: str) -> Optional[str]:
|
|
||||||
"""Resolve short link to its final URL
|
|
||||||
|
|
||||||
Uses HTTP HEAD request with redirects to get the final URL.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: Short link URL to resolve
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Final URL after redirects, or None if resolution failed
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
response = requests.head(
|
|
||||||
url,
|
|
||||||
allow_redirects=True,
|
|
||||||
timeout=self.SHORT_LINK_TIMEOUT
|
|
||||||
)
|
|
||||||
if response.status_code == 200:
|
|
||||||
return response.url
|
|
||||||
return None
|
|
||||||
except (requests.RequestException, OSError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def parse(self, url: str) -> Optional[ParsedLink]:
|
|
||||||
"""Parse a URL and extract platform, content type, and ID
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: URL to parse
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ParsedLink with extracted information, or None if URL is not recognized
|
|
||||||
"""
|
|
||||||
if not url or not isinstance(url, str):
|
|
||||||
return None
|
|
||||||
|
|
||||||
url = url.strip()
|
|
||||||
|
|
||||||
# Try b23.tv short link first
|
|
||||||
if self.B23_TV_PATTERN.match(url):
|
|
||||||
resolved_url = self._resolve_shortlink(url)
|
|
||||||
if resolved_url:
|
|
||||||
# Recursively parse the resolved URL
|
|
||||||
result = self._parse_standard_url(resolved_url)
|
|
||||||
if result:
|
|
||||||
result.original_url = url
|
|
||||||
result.resolved_url = resolved_url
|
|
||||||
return result
|
|
||||||
return None
|
|
||||||
|
|
||||||
return self._parse_standard_url(url)
|
|
||||||
|
|
||||||
def _parse_standard_url(self, url: str) -> Optional[ParsedLink]:
|
|
||||||
"""Parse a standard (non-short) URL
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: URL to parse
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ParsedLink with extracted information, or None if URL is not recognized
|
|
||||||
"""
|
|
||||||
# Try Bilibili video
|
|
||||||
match = self.BILIBILI_VIDEO_PATTERN.match(url)
|
|
||||||
if match:
|
|
||||||
return ParsedLink(
|
|
||||||
platform="bilibili",
|
|
||||||
content_type="video",
|
|
||||||
content_id=match.group(1),
|
|
||||||
original_url=url,
|
|
||||||
resolved_url=url
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try Bilibili article
|
|
||||||
match = self.BILIBILI_ARTICLE_PATTERN.match(url)
|
|
||||||
if match:
|
|
||||||
return ParsedLink(
|
|
||||||
platform="bilibili",
|
|
||||||
content_type="article",
|
|
||||||
content_id=match.group(1),
|
|
||||||
original_url=url,
|
|
||||||
resolved_url=url
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try Zhihu answer
|
|
||||||
match = self.ZHIHU_ANSWER_PATTERN.match(url)
|
|
||||||
if match:
|
|
||||||
return ParsedLink(
|
|
||||||
platform="zhihu",
|
|
||||||
content_type="answer",
|
|
||||||
content_id=match.group(1),
|
|
||||||
original_url=url,
|
|
||||||
resolved_url=url
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try Zhihu zhuanlan
|
|
||||||
match = self.ZHIHU_ZHUANLAN_PATTERN.match(url)
|
|
||||||
if match:
|
|
||||||
return ParsedLink(
|
|
||||||
platform="zhihu",
|
|
||||||
content_type="zhuanlan",
|
|
||||||
content_id=match.group(1),
|
|
||||||
original_url=url,
|
|
||||||
resolved_url=url
|
|
||||||
)
|
|
||||||
|
|
||||||
# URL not recognized
|
|
||||||
return None
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
"""Pipeline engine - orchestrates multiple processors
|
|
||||||
|
|
||||||
The Pipeline class coordinates the workflow:
|
|
||||||
1. Parse URL to extract platform and content ID
|
|
||||||
2. Run processors in order (parse, download, asr, analyze)
|
|
||||||
3. Handle errors and state persistence
|
|
||||||
4. Return results
|
|
||||||
|
|
||||||
Key design decisions:
|
|
||||||
- Sequential execution (simpler, easier to debug)
|
|
||||||
- State persistence after each step (via WorkflowStateManager)
|
|
||||||
- Simple progress logging
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
from kb_pipeline.config import get_settings
|
|
||||||
from kb_pipeline.link_parser import LinkParser, ParsedLink
|
|
||||||
from kb_pipeline.processors.base import BaseProcessor
|
|
||||||
from kb_pipeline.schemas import PipelineConfig, PipelineInput, PipelineOutput
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class Pipeline:
|
|
||||||
"""Pipeline engine - orchestrates multiple processors
|
|
||||||
|
|
||||||
Orchestrates the workflow:
|
|
||||||
1. Parse URL to extract platform and content ID
|
|
||||||
2. Run processors in order (parse, download, asr, analyze)
|
|
||||||
3. Handle errors and state persistence
|
|
||||||
4. Return results
|
|
||||||
|
|
||||||
Key design decisions:
|
|
||||||
- Sequential execution (simpler, easier to debug)
|
|
||||||
- State persistence after each step (via WorkflowStateManager)
|
|
||||||
- Simple progress logging
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
processors: List[BaseProcessor],
|
|
||||||
config: Optional[PipelineConfig] = None,
|
|
||||||
output_dir: Optional[Path] = None,
|
|
||||||
):
|
|
||||||
"""Initialize the Pipeline.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
processors: List of processor instances to use
|
|
||||||
config: Optional PipelineConfig for pipeline settings
|
|
||||||
output_dir: Optional output directory for content
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If a processor's STEP_NAME is not in config.steps
|
|
||||||
"""
|
|
||||||
self.processors: Dict[str, BaseProcessor] = {p.STEP_NAME: p for p in processors}
|
|
||||||
self.config = config or PipelineConfig()
|
|
||||||
self.settings = get_settings()
|
|
||||||
self.output_dir = output_dir or Path(self.settings.intermediate_path)
|
|
||||||
|
|
||||||
# Validate processors - each processor's STEP_NAME must be in config.steps
|
|
||||||
for step_name in self.processors:
|
|
||||||
if step_name not in self.config.steps:
|
|
||||||
raise ValueError(f"Unknown processor: {step_name}")
|
|
||||||
|
|
||||||
def _parse_url(self, url: str) -> Optional[ParsedLink]:
|
|
||||||
"""Parse URL using LinkParser.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: URL to parse
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ParsedLink with extracted information, or None if URL is not recognized
|
|
||||||
"""
|
|
||||||
if not url:
|
|
||||||
return None
|
|
||||||
|
|
||||||
parser = LinkParser()
|
|
||||||
parsed = parser.parse(url)
|
|
||||||
|
|
||||||
if not parsed:
|
|
||||||
logger.warning(f"Failed to parse URL: {url}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
def _get_content_dir(self, parsed: ParsedLink) -> Path:
|
|
||||||
"""Get content directory from parsed URL.
|
|
||||||
|
|
||||||
Creates the directory if it doesn't exist.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
parsed: ParsedLink with platform and content_id
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to the content directory
|
|
||||||
"""
|
|
||||||
content_dir = self.output_dir / f"{parsed.platform}_{parsed.content_id}"
|
|
||||||
content_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
return content_dir
|
|
||||||
|
|
||||||
async def run(self, url: str) -> PipelineOutput:
|
|
||||||
"""Run the pipeline for a given URL.
|
|
||||||
|
|
||||||
Executes all configured processors in order:
|
|
||||||
1. Parse URL to get platform and content_id
|
|
||||||
2. Create content directory
|
|
||||||
3. Run each processor sequentially
|
|
||||||
4. Handle errors and state persistence
|
|
||||||
5. Return results
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: URL to process
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
PipelineOutput with success status, results, and content_dir
|
|
||||||
"""
|
|
||||||
# 1. Parse URL
|
|
||||||
parsed = self._parse_url(url)
|
|
||||||
if not parsed:
|
|
||||||
return PipelineOutput(
|
|
||||||
success=False,
|
|
||||||
error_message=f"Failed to parse URL: {url}",
|
|
||||||
results={},
|
|
||||||
content_dir=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2. Create content directory
|
|
||||||
content_dir = self._get_content_dir(parsed)
|
|
||||||
|
|
||||||
# 3. Initialize state manager
|
|
||||||
from kb_pipeline.state import WorkflowStateManager
|
|
||||||
state_manager = WorkflowStateManager(content_dir)
|
|
||||||
|
|
||||||
# Get or create workflow state
|
|
||||||
state = state_manager.get_or_create(
|
|
||||||
content_id=parsed.content_id,
|
|
||||||
platform=parsed.platform,
|
|
||||||
source_url=url,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. Run processors in order
|
|
||||||
results: Dict = {}
|
|
||||||
|
|
||||||
for step_name in self.config.steps:
|
|
||||||
if step_name not in self.processors:
|
|
||||||
logger.warning(f"Step {step_name} not found in processors, skipping")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check if step should be skipped
|
|
||||||
if self.config.skip_completed and state.is_step_completed(step_name):
|
|
||||||
# Ask user whether to re-run the completed step
|
|
||||||
should_rerun = self._ask_rerun_step(step_name, content_dir)
|
|
||||||
|
|
||||||
if should_rerun:
|
|
||||||
logger.info(f"Re-running completed step: {step_name}")
|
|
||||||
# Clear the step's state record so it can be re-executed
|
|
||||||
if step_name in state.steps:
|
|
||||||
del state.steps[step_name]
|
|
||||||
state_manager.save(state)
|
|
||||||
else:
|
|
||||||
logger.info(f"Skipping completed step: {step_name}")
|
|
||||||
# Scan existing files and populate results for skipped steps
|
|
||||||
self._populate_skipped_step_results(step_name, results, content_dir, parsed)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip ASR for text-based content types
|
|
||||||
text_content_types = {"article", "answer", "zhuanlan", "question"}
|
|
||||||
if step_name == "asr" and parsed.content_type in text_content_types:
|
|
||||||
logger.info(f"Skipping ASR for text content type: {parsed.content_type}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
processor = self.processors[step_name]
|
|
||||||
|
|
||||||
# Mark step as started
|
|
||||||
state.mark_step_started(step_name)
|
|
||||||
state_manager.save(state)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Create step input based on step type
|
|
||||||
from kb_pipeline.schemas import (
|
|
||||||
ParseInput, DownloadInput, ASRInput, AnalyzeInput, ArchiveInput
|
|
||||||
)
|
|
||||||
|
|
||||||
if step_name == "parse":
|
|
||||||
step_input = ParseInput(
|
|
||||||
url=url,
|
|
||||||
content_id=parsed.content_id,
|
|
||||||
platform=parsed.platform,
|
|
||||||
)
|
|
||||||
elif step_name == "download":
|
|
||||||
step_input = DownloadInput(
|
|
||||||
content_id=parsed.content_id,
|
|
||||||
platform=parsed.platform,
|
|
||||||
content_dir=str(content_dir),
|
|
||||||
content_type=parsed.content_type,
|
|
||||||
source_url=url,
|
|
||||||
)
|
|
||||||
elif step_name == "asr":
|
|
||||||
# ASR needs video path from download step
|
|
||||||
download_result = results.get("download", {})
|
|
||||||
video_path = download_result.get("content_file", "")
|
|
||||||
|
|
||||||
step_input = ASRInput(
|
|
||||||
content_id=parsed.content_id,
|
|
||||||
platform=parsed.platform,
|
|
||||||
video_path=video_path,
|
|
||||||
content_type=parsed.content_type,
|
|
||||||
content_dir=str(content_dir),
|
|
||||||
)
|
|
||||||
elif step_name == "analyze":
|
|
||||||
# Analyze needs metadata and content files
|
|
||||||
download_result = results.get("download", {})
|
|
||||||
asr_result = results.get("asr", {})
|
|
||||||
metadata_file = download_result.get("metadata_file", "")
|
|
||||||
# Use subtitle JSON if available, otherwise content file
|
|
||||||
content_file = asr_result.get("output_files", {}).get("json", "") or download_result.get("content_file", "")
|
|
||||||
|
|
||||||
step_input = AnalyzeInput(
|
|
||||||
content_id=parsed.content_id,
|
|
||||||
platform=parsed.platform,
|
|
||||||
metadata_file=metadata_file,
|
|
||||||
content_file=content_file,
|
|
||||||
content_type=parsed.content_type,
|
|
||||||
output_file=str(content_dir / "analysis.md"),
|
|
||||||
)
|
|
||||||
elif step_name == "archive":
|
|
||||||
# Archive needs analysis file from analyze step
|
|
||||||
analyze_result = results.get("analyze", {})
|
|
||||||
analysis_file = analyze_result.get("analysis_file", "")
|
|
||||||
|
|
||||||
step_input = ArchiveInput(
|
|
||||||
content_id=parsed.content_id,
|
|
||||||
platform=parsed.platform,
|
|
||||||
content_dir=str(content_dir),
|
|
||||||
analysis_file=analysis_file,
|
|
||||||
content_type=parsed.content_type,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Generic step input
|
|
||||||
from kb_pipeline.schemas import StepInput
|
|
||||||
step_input = StepInput(
|
|
||||||
content_id=parsed.content_id,
|
|
||||||
platform=parsed.platform,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Run processor
|
|
||||||
step_output = await processor.process(step_input)
|
|
||||||
|
|
||||||
# Record result
|
|
||||||
results[step_name] = {
|
|
||||||
"success": step_output.success,
|
|
||||||
"error_message": step_output.error_message,
|
|
||||||
}
|
|
||||||
|
|
||||||
if step_output.success:
|
|
||||||
# Add output fields to results
|
|
||||||
for field_name in ["metadata_file", "content_file", "title", "author",
|
|
||||||
"analysis_file", "summary", "output_files",
|
|
||||||
"archive_file", "skipped"]:
|
|
||||||
if hasattr(step_output, field_name):
|
|
||||||
results[step_name][field_name] = getattr(step_output, field_name)
|
|
||||||
|
|
||||||
state.mark_step_completed(step_name)
|
|
||||||
else:
|
|
||||||
state.mark_step_failed(step_name, step_output.error_message or "Unknown error")
|
|
||||||
# Stop pipeline on error
|
|
||||||
state_manager.save(state)
|
|
||||||
return PipelineOutput(
|
|
||||||
success=False,
|
|
||||||
error_message=f"Step {step_name} failed: {step_output.error_message}",
|
|
||||||
results=results,
|
|
||||||
content_dir=content_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error in step {step_name}: {e}")
|
|
||||||
state.mark_step_failed(step_name, str(e))
|
|
||||||
state_manager.save(state)
|
|
||||||
return PipelineOutput(
|
|
||||||
success=False,
|
|
||||||
error_message=f"Step {step_name} raised exception: {e}",
|
|
||||||
results=results,
|
|
||||||
content_dir=content_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Save state after each successful step
|
|
||||||
state_manager.save(state)
|
|
||||||
|
|
||||||
# 5. Return success
|
|
||||||
return PipelineOutput(
|
|
||||||
success=True,
|
|
||||||
error_message=None,
|
|
||||||
results=results,
|
|
||||||
content_dir=content_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _ask_rerun_step(self, step_name: str, content_dir: Path) -> bool:
|
|
||||||
"""Ask user whether to re-run a completed step.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
step_name: Name of the step to potentially re-run
|
|
||||||
content_dir: Content directory path
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if user wants to re-run, False to skip
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from rich.console import Console
|
|
||||||
from rich.prompt import Confirm
|
|
||||||
|
|
||||||
console = Console()
|
|
||||||
return Confirm.ask(
|
|
||||||
f"Step '{step_name}' is already completed. Re-run it?",
|
|
||||||
default=False
|
|
||||||
)
|
|
||||||
except ImportError:
|
|
||||||
# Fallback to basic input if rich is not available
|
|
||||||
response = input(f"Step '{step_name}' is already completed. Re-run it? [y/N] ")
|
|
||||||
return response.lower() in ('y', 'yes')
|
|
||||||
|
|
||||||
def _populate_skipped_step_results(
|
|
||||||
self,
|
|
||||||
step_name: str,
|
|
||||||
results: Dict,
|
|
||||||
content_dir: Path,
|
|
||||||
parsed: ParsedLink
|
|
||||||
) -> None:
|
|
||||||
"""Populate results for skipped steps by scanning existing files.
|
|
||||||
|
|
||||||
This ensures downstream steps can find the outputs they need,
|
|
||||||
even when a step is skipped.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
step_name: Name of the skipped step
|
|
||||||
results: Results dictionary to populate
|
|
||||||
content_dir: Content directory path
|
|
||||||
parsed: Parsed link with platform and content_id
|
|
||||||
"""
|
|
||||||
if step_name == "asr":
|
|
||||||
# Scan for subtitle files generated by ASR
|
|
||||||
# Try both new naming convention (with _subtitle suffix) and old convention (without suffix)
|
|
||||||
base_name_new = f"{parsed.platform}_{parsed.content_id}_subtitle"
|
|
||||||
base_name_old = f"{parsed.platform}_{parsed.content_id}"
|
|
||||||
|
|
||||||
json_path = content_dir / f"{base_name_new}.json"
|
|
||||||
if not json_path.exists():
|
|
||||||
json_path = content_dir / f"{base_name_old}.json"
|
|
||||||
|
|
||||||
srt_path = content_dir / f"{base_name_new}.srt"
|
|
||||||
if not srt_path.exists():
|
|
||||||
srt_path = content_dir / f"{base_name_old}.srt"
|
|
||||||
|
|
||||||
output_files = {}
|
|
||||||
if json_path.exists():
|
|
||||||
output_files["json"] = str(json_path)
|
|
||||||
if srt_path.exists():
|
|
||||||
output_files["srt"] = str(srt_path)
|
|
||||||
|
|
||||||
if output_files:
|
|
||||||
results["asr"] = {
|
|
||||||
"success": True,
|
|
||||||
"error_message": None,
|
|
||||||
"output_files": output_files,
|
|
||||||
}
|
|
||||||
logger.info(f"Found existing ASR outputs: {output_files}")
|
|
||||||
else:
|
|
||||||
logger.warning(f"No existing ASR outputs found in {content_dir}")
|
|
||||||
results["asr"] = {
|
|
||||||
"success": False,
|
|
||||||
"error_message": "No existing subtitle files found",
|
|
||||||
"output_files": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
elif step_name == "download":
|
|
||||||
# Scan for metadata file
|
|
||||||
metadata_path = content_dir / "metadata.json"
|
|
||||||
if metadata_path.exists():
|
|
||||||
results["download"] = {
|
|
||||||
"success": True,
|
|
||||||
"error_message": None,
|
|
||||||
"metadata_file": str(metadata_path),
|
|
||||||
}
|
|
||||||
logger.info(f"Found existing download metadata: {metadata_path}")
|
|
||||||
|
|
||||||
elif step_name == "analyze":
|
|
||||||
# Scan for analysis output
|
|
||||||
analysis_path = content_dir / "analysis.md"
|
|
||||||
if analysis_path.exists():
|
|
||||||
results["analyze"] = {
|
|
||||||
"success": True,
|
|
||||||
"error_message": None,
|
|
||||||
"analysis_file": str(analysis_path),
|
|
||||||
}
|
|
||||||
logger.info(f"Found existing analysis: {analysis_path}")
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""Platform Adapters for KB Pipeline
|
|
||||||
|
|
||||||
This module provides platform-specific adapters for content extraction
|
|
||||||
from various platforms (Bilibili, Zhihu, etc.).
|
|
||||||
|
|
||||||
Each adapter implements the PlatformAdapter interface:
|
|
||||||
- get_content_type: Identify content type from URL
|
|
||||||
- fetch_metadata: Fetch content metadata
|
|
||||||
- fetch_content: Fetch content body in Markdown format
|
|
||||||
- get_output_filename: Generate output filename
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .base import PlatformAdapter
|
|
||||||
from .bilibili import BilibiliAdapter
|
|
||||||
from .zhihu import ZhihuAdapter
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"PlatformAdapter",
|
|
||||||
"BilibiliAdapter",
|
|
||||||
"ZhihuAdapter",
|
|
||||||
]
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
"""Platform Adapter Base Class
|
|
||||||
|
|
||||||
Provides an abstract base class for platform-specific content adapters.
|
|
||||||
Each platform (Bilibili, Zhihu, etc.) should implement a concrete adapter
|
|
||||||
that handles metadata extraction and content fetching.
|
|
||||||
|
|
||||||
Purpose: Define a unified interface for multi-platform content extraction
|
|
||||||
"""
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from typing import Dict, Any
|
|
||||||
|
|
||||||
|
|
||||||
class PlatformAdapter(ABC):
|
|
||||||
"""Abstract base class for platform-specific content adapters
|
|
||||||
|
|
||||||
A platform adapter encapsulates the logic for:
|
|
||||||
- Identifying content type from URL (video, article, answer, etc.)
|
|
||||||
- Fetching metadata for a given content ID
|
|
||||||
- Fetching content body in Markdown format
|
|
||||||
- Generating output filenames
|
|
||||||
|
|
||||||
Subclasses must implement all abstract methods to provide
|
|
||||||
platform-specific functionality.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
class BilibiliAdapter(PlatformAdapter):
|
|
||||||
def get_content_type(self, url: str) -> str:
|
|
||||||
if '/video/' in url:
|
|
||||||
return 'video'
|
|
||||||
elif '/read/' in url:
|
|
||||||
return 'article'
|
|
||||||
return 'unknown'
|
|
||||||
|
|
||||||
async def fetch_metadata(self, content_id: str, content_type: str) -> Dict[str, Any]:
|
|
||||||
# Platform-specific implementation
|
|
||||||
...
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_content_type(self, url: str) -> str:
|
|
||||||
"""Identify content type from URL
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: The content URL to analyze
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Content type string (e.g., "video", "article", "answer", "zhuanlan")
|
|
||||||
|
|
||||||
Example:
|
|
||||||
adapter.get_content_type("https://www.bilibili.com/video/BV1xx...")
|
|
||||||
-> "video"
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def fetch_metadata(self, content_id: str, content_type: str) -> Dict[str, Any]:
|
|
||||||
"""Fetch metadata for the given content
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content_id: Platform-specific content identifier (e.g., BV号, CV号)
|
|
||||||
content_type: Type of content ("video", "article", etc.)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary containing metadata fields. Structure varies by platform
|
|
||||||
and content type, but typically includes:
|
|
||||||
- title: Content title
|
|
||||||
- author: Author name
|
|
||||||
- url: Original content URL
|
|
||||||
- Platform-specific fields
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
NetworkError: If network request fails
|
|
||||||
AuthError: If authentication fails or quota exceeded
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def fetch_content(self, content_id: str, content_type: str) -> str:
|
|
||||||
"""Fetch content body in Markdown format
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content_id: Platform-specific content identifier
|
|
||||||
content_type: Type of content
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Markdown-formatted content string with YAML frontmatter:
|
|
||||||
---
|
|
||||||
title: Article Title
|
|
||||||
author: Author Name
|
|
||||||
source_url: https://...
|
|
||||||
platform: bilibili
|
|
||||||
content_type: article
|
|
||||||
fetched_at: 2024-01-15T10:30:00
|
|
||||||
---
|
|
||||||
|
|
||||||
# Article Content...
|
|
||||||
|
|
||||||
Note:
|
|
||||||
For video content, this may return an empty string or
|
|
||||||
placeholder text, as video content extraction is handled
|
|
||||||
separately (e.g., ASR transcription).
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_output_filename(self, content_id: str, content_type: str, suffix: str) -> str:
|
|
||||||
"""Generate output filename for content
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content_id: Platform-specific content identifier
|
|
||||||
content_type: Type of content
|
|
||||||
suffix: Filename suffix (e.g., "metadata.json", "content.md")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Filename string without directory path
|
|
||||||
|
|
||||||
Example:
|
|
||||||
adapter.get_output_filename("BV1xx...", "video", "metadata.json")
|
|
||||||
-> "bilibili_BV1xx..._metadata.json"
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
@@ -1,485 +0,0 @@
|
|||||||
"""Bilibili Platform Adapter
|
|
||||||
|
|
||||||
Implements the PlatformAdapter interface for Bilibili platform content.
|
|
||||||
Supports video and article metadata extraction and content fetching.
|
|
||||||
|
|
||||||
Key features:
|
|
||||||
- Video metadata extraction (BV号)
|
|
||||||
- Article metadata extraction (CV号)
|
|
||||||
- Video download using yt-dlp (for ASR/Whisper)
|
|
||||||
- Automatic retry on network errors (3 attempts with exponential backoff)
|
|
||||||
- State recording for failed steps
|
|
||||||
- Fallback from API to Playwright for 403 errors (handled by BilibiliExtractor)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
|
|
||||||
from .base import PlatformAdapter
|
|
||||||
from ..errors import NetworkError, AuthError
|
|
||||||
from ..retry import retry_on_network_error
|
|
||||||
from ..state import WorkflowStateManager
|
|
||||||
from kb_workflow.metadata.bilibili_extractor import BilibiliExtractor
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class BilibiliAdapter(PlatformAdapter):
|
|
||||||
"""Bilibili platform adapter for video and article content
|
|
||||||
|
|
||||||
Implements the PlatformAdapter interface to provide:
|
|
||||||
- Content type identification from URL
|
|
||||||
- Video metadata extraction
|
|
||||||
- Article metadata extraction
|
|
||||||
- Article content fetching in Markdown format
|
|
||||||
- Automatic error retry and state recording
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
PLATFORM: Platform identifier string ("bilibili")
|
|
||||||
_extractor: BilibiliExtractor instance for actual extraction
|
|
||||||
_state_manager: Optional WorkflowStateManager for recording step status
|
|
||||||
|
|
||||||
Example:
|
|
||||||
adapter = BilibiliAdapter()
|
|
||||||
content_type = adapter.get_content_type("https://www.bilibili.com/video/BV1xx...")
|
|
||||||
# -> "video"
|
|
||||||
|
|
||||||
metadata = await adapter.fetch_metadata("BV1xx...", "video")
|
|
||||||
# -> {"metadata": {...}, "stats": {...}, ...}
|
|
||||||
"""
|
|
||||||
|
|
||||||
PLATFORM = "bilibili"
|
|
||||||
|
|
||||||
# URL patterns for content type identification
|
|
||||||
VIDEO_PATTERN = re.compile(r'bilibili\.com/video/(BV[a-zA-Z0-9]+)')
|
|
||||||
ARTICLE_PATTERN = re.compile(r'bilibili\.com/read/(cv\d+)')
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
headless: bool = True,
|
|
||||||
timeout: int = 30,
|
|
||||||
state_manager: Optional[WorkflowStateManager] = None
|
|
||||||
):
|
|
||||||
"""Initialize BilibiliAdapter
|
|
||||||
|
|
||||||
Args:
|
|
||||||
headless: Whether to use headless mode for Playwright (default True)
|
|
||||||
timeout: Request timeout in seconds (default 30)
|
|
||||||
state_manager: Optional WorkflowStateManager for recording step status
|
|
||||||
"""
|
|
||||||
self._extractor = BilibiliExtractor(headless=headless, timeout=timeout)
|
|
||||||
self._state_manager = state_manager
|
|
||||||
|
|
||||||
def get_content_type(self, url: str) -> str:
|
|
||||||
"""Identify content type from URL
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: Bilibili URL to analyze
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Content type string: "video", "article", or "unknown"
|
|
||||||
|
|
||||||
Example:
|
|
||||||
adapter.get_content_type("https://www.bilibili.com/video/BV1xx...")
|
|
||||||
-> "video"
|
|
||||||
"""
|
|
||||||
if self.VIDEO_PATTERN.search(url):
|
|
||||||
return "video"
|
|
||||||
if self.ARTICLE_PATTERN.search(url):
|
|
||||||
return "article"
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
async def fetch_metadata(
|
|
||||||
self,
|
|
||||||
content_id: str,
|
|
||||||
content_type: str
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Fetch metadata for the given content
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content_id: BV号 for videos, CV号 for articles
|
|
||||||
content_type: "video" or "article"
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary containing metadata. Structure varies by content type:
|
|
||||||
- Video: {metadata: {...}, stats: {...}, description: str}
|
|
||||||
- Article: {cvid, title, author, publish_time, ...}
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
NetworkError: If network request fails after 3 retries
|
|
||||||
AuthError: If authentication fails (403/quota exceeded)
|
|
||||||
ValueError: If content_type is not supported
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return await self._fetch_metadata_with_retry(content_id, content_type)
|
|
||||||
except (NetworkError, AuthError) as e:
|
|
||||||
# Record failure to state manager only after all retries exhausted
|
|
||||||
self._record_step_failed("metadata", str(e))
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
# Check if this is an API error that should be treated as network error
|
|
||||||
if self._is_network_related_error(e):
|
|
||||||
network_error = NetworkError(
|
|
||||||
step_name="metadata",
|
|
||||||
input_summary=f"content_id={content_id}, type={content_type}",
|
|
||||||
cause=e
|
|
||||||
)
|
|
||||||
self._record_step_failed("metadata", str(network_error))
|
|
||||||
raise network_error
|
|
||||||
raise
|
|
||||||
|
|
||||||
@retry_on_network_error
|
|
||||||
async def _fetch_metadata_with_retry(
|
|
||||||
self,
|
|
||||||
content_id: str,
|
|
||||||
content_type: str
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Internal method with retry logic for metadata fetching
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content_id: BV号 for videos, CV号 for articles
|
|
||||||
content_type: "video" or "article"
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary containing metadata
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
NetworkError: If network request fails
|
|
||||||
AuthError: If authentication fails
|
|
||||||
ValueError: If content_type is not supported
|
|
||||||
"""
|
|
||||||
if content_type == "video":
|
|
||||||
# Call async method directly to avoid nested asyncio.run()
|
|
||||||
return await self._extractor._extract_video_async(content_id)
|
|
||||||
elif content_type == "article":
|
|
||||||
# Call async method directly to avoid nested asyncio.run()
|
|
||||||
return await self._extractor._extract_article_async(content_id)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unsupported content type: {content_type}")
|
|
||||||
|
|
||||||
def _is_network_related_error(self, error: Exception) -> bool:
|
|
||||||
"""Check if an error is network-related and should trigger retry
|
|
||||||
|
|
||||||
Args:
|
|
||||||
error: Exception to check
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if error is network-related
|
|
||||||
"""
|
|
||||||
error_str = str(error).lower()
|
|
||||||
network_indicators = [
|
|
||||||
'timeout', 'connection', 'network', '503', '502', '500',
|
|
||||||
'connection refused', 'connection reset', 'eof',
|
|
||||||
]
|
|
||||||
return any(indicator in error_str for indicator in network_indicators)
|
|
||||||
|
|
||||||
async def fetch_content(
|
|
||||||
self,
|
|
||||||
content_id: str,
|
|
||||||
content_type: str
|
|
||||||
) -> str:
|
|
||||||
"""Fetch content body in Markdown format
|
|
||||||
|
|
||||||
For videos, this returns an empty string as video content
|
|
||||||
extraction is handled separately (ASR transcription).
|
|
||||||
|
|
||||||
For articles, this fetches the article content and formats
|
|
||||||
it with YAML frontmatter.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content_id: CV号 for articles
|
|
||||||
content_type: "video" or "article"
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Markdown-formatted content string with YAML frontmatter,
|
|
||||||
or empty string for videos
|
|
||||||
"""
|
|
||||||
if content_type == "video":
|
|
||||||
# Video content is handled by ASR in Phase 3
|
|
||||||
return ""
|
|
||||||
|
|
||||||
if content_type == "article":
|
|
||||||
# Fetch article metadata first
|
|
||||||
metadata = await self.fetch_metadata(content_id, "article")
|
|
||||||
|
|
||||||
# Fetch article content (requires additional implementation)
|
|
||||||
content = await self._fetch_article_content(content_id)
|
|
||||||
|
|
||||||
# Format with YAML frontmatter
|
|
||||||
return self._format_content_markdown(metadata, content)
|
|
||||||
|
|
||||||
raise ValueError(f"Unsupported content type: {content_type}")
|
|
||||||
|
|
||||||
async def _fetch_article_content(self, cvid: str) -> str:
|
|
||||||
"""Fetch article body content
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cvid: Article CV号
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Raw article content string
|
|
||||||
|
|
||||||
Note:
|
|
||||||
This method currently returns a placeholder.
|
|
||||||
Full implementation would use additional API calls
|
|
||||||
or Playwright to fetch the article body.
|
|
||||||
"""
|
|
||||||
# TODO: Implement full article content fetching
|
|
||||||
# This would require additional API endpoints or Playwright
|
|
||||||
logger.warning(f"Article content fetching not fully implemented for {cvid}")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def _format_content_markdown(
|
|
||||||
self,
|
|
||||||
metadata: Dict[str, Any],
|
|
||||||
content: str,
|
|
||||||
content_type: str = "article"
|
|
||||||
) -> str:
|
|
||||||
"""Format content with YAML frontmatter
|
|
||||||
|
|
||||||
Args:
|
|
||||||
metadata: Article metadata dictionary
|
|
||||||
content: Article body content
|
|
||||||
content_type: Content type string
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Markdown string with YAML frontmatter
|
|
||||||
"""
|
|
||||||
import yaml
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
frontmatter = {
|
|
||||||
"title": metadata.get("title", ""),
|
|
||||||
"author": metadata.get("author", ""),
|
|
||||||
"source_url": metadata.get("url", ""),
|
|
||||||
"platform": self.PLATFORM,
|
|
||||||
"content_type": content_type,
|
|
||||||
"fetched_at": datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add additional metadata if available
|
|
||||||
if "cvid" in metadata:
|
|
||||||
frontmatter["cvid"] = metadata["cvid"]
|
|
||||||
if "mid" in metadata:
|
|
||||||
frontmatter["author_id"] = metadata["mid"]
|
|
||||||
if "word_count" in metadata:
|
|
||||||
frontmatter["word_count"] = metadata["word_count"]
|
|
||||||
|
|
||||||
yaml_str = yaml.dump(frontmatter, allow_unicode=True, sort_keys=False)
|
|
||||||
return f"---\n{yaml_str}---\n\n{content}"
|
|
||||||
|
|
||||||
def get_output_filename(
|
|
||||||
self,
|
|
||||||
content_id: str,
|
|
||||||
content_type: str,
|
|
||||||
suffix: str
|
|
||||||
) -> str:
|
|
||||||
"""Generate output filename for content
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content_id: BV号 or CV号
|
|
||||||
content_type: "video" or "article"
|
|
||||||
suffix: Filename suffix (e.g., "metadata.json", "content.md")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Filename string in format "bilibili_{content_id}_{suffix}"
|
|
||||||
|
|
||||||
Example:
|
|
||||||
adapter.get_output_filename("BV1xx...", "video", "metadata.json")
|
|
||||||
-> "bilibili_BV1xx..._metadata.json"
|
|
||||||
"""
|
|
||||||
return f"{self.PLATFORM}_{content_id}_{suffix}"
|
|
||||||
|
|
||||||
def _record_step_failed(self, step_name: str, error_message: str) -> None:
|
|
||||||
"""Record step failure to state manager
|
|
||||||
|
|
||||||
Args:
|
|
||||||
step_name: Name of the failed step
|
|
||||||
error_message: Error message to record
|
|
||||||
"""
|
|
||||||
if self._state_manager is not None:
|
|
||||||
self._state_manager.mark_step_failed(step_name, error_message)
|
|
||||||
logger.warning(f"Step '{step_name}' failed: {error_message}")
|
|
||||||
|
|
||||||
async def download_video(
|
|
||||||
self,
|
|
||||||
bvid: str,
|
|
||||||
output_dir: Path,
|
|
||||||
quality: int = 32
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Download Bilibili video using yt-dlp.
|
|
||||||
|
|
||||||
Downloads only audio (sufficient for Whisper ASR) to save bandwidth.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
bvid: Bilibili video ID (BV号)
|
|
||||||
output_dir: Directory to save the video
|
|
||||||
quality: Video quality (16=360P, 32=480P, 64=720P, 80=1080P)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with:
|
|
||||||
- success: bool
|
|
||||||
- filepath: str or None
|
|
||||||
- message: str
|
|
||||||
"""
|
|
||||||
result = {"success": False, "filepath": None, "message": ""}
|
|
||||||
|
|
||||||
try:
|
|
||||||
from ..config import get_settings
|
|
||||||
|
|
||||||
url = f"https://www.bilibili.com/video/{bvid}"
|
|
||||||
|
|
||||||
# Get cookie from settings or environment
|
|
||||||
settings = get_settings()
|
|
||||||
cookie = settings.bilibili_cookie.get_secret_value() or os.environ.get('BILIBILI_COOKIE', '')
|
|
||||||
|
|
||||||
# Create cookie file if needed
|
|
||||||
cookie_file = None
|
|
||||||
if cookie:
|
|
||||||
cookie_file = self._create_cookie_file(cookie)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get video title for filename
|
|
||||||
title = await self._get_video_title(url, cookie_file)
|
|
||||||
safe_title = self._sanitize_filename(title, bvid)
|
|
||||||
|
|
||||||
# Build yt-dlp command - download best audio only for ASR
|
|
||||||
# This saves bandwidth since Whisper only needs audio
|
|
||||||
output_template = str(output_dir / f"{safe_title}.%(ext)s")
|
|
||||||
|
|
||||||
# Use best audio format for ASR
|
|
||||||
cmd = [
|
|
||||||
"yt-dlp",
|
|
||||||
"-f", "bestaudio/best",
|
|
||||||
"-o", output_template,
|
|
||||||
"--no-download-archive",
|
|
||||||
"-q",
|
|
||||||
url
|
|
||||||
]
|
|
||||||
|
|
||||||
if cookie_file:
|
|
||||||
cmd.extend(["--cookies", cookie_file])
|
|
||||||
|
|
||||||
logger.info(f"Downloading video: {bvid}")
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
*cmd,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE
|
|
||||||
)
|
|
||||||
stdout, stderr = await proc.communicate()
|
|
||||||
|
|
||||||
# Find downloaded file
|
|
||||||
downloaded_files = list(output_dir.glob(f"{safe_title}.*"))
|
|
||||||
video_extensions = {'.mp4', '.mkv', '.webm', '.mp3', '.m4a', '.opus', '.wav'}
|
|
||||||
downloaded_files = [f for f in downloaded_files if f.suffix.lower() in video_extensions]
|
|
||||||
|
|
||||||
if downloaded_files:
|
|
||||||
video_file = downloaded_files[0]
|
|
||||||
result["success"] = True
|
|
||||||
result["filepath"] = str(video_file)
|
|
||||||
result["message"] = f"Downloaded: {video_file.name}"
|
|
||||||
logger.info(f"Video downloaded: {video_file}")
|
|
||||||
else:
|
|
||||||
# Try to find any new media file in output_dir
|
|
||||||
all_media = list(output_dir.glob("*.*"))
|
|
||||||
all_media = [f for f in all_media if f.suffix.lower() in video_extensions]
|
|
||||||
if all_media:
|
|
||||||
# Get the most recently modified file
|
|
||||||
video_file = max(all_media, key=lambda f: f.stat().st_mtime)
|
|
||||||
result["success"] = True
|
|
||||||
result["filepath"] = str(video_file)
|
|
||||||
result["message"] = f"Downloaded: {video_file.name}"
|
|
||||||
logger.info(f"Video downloaded (fallback): {video_file}")
|
|
||||||
else:
|
|
||||||
result["message"] = "No downloaded file found"
|
|
||||||
logger.warning(f"No downloaded file found for {bvid}")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Clean up cookie file
|
|
||||||
if cookie_file and os.path.exists(cookie_file):
|
|
||||||
os.remove(cookie_file)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
result["message"] = f"Download error: {str(e)}"
|
|
||||||
logger.error(f"Video download failed for {bvid}: {e}")
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def _get_video_title(self, url: str, cookie_file: Optional[str] = None) -> str:
|
|
||||||
"""Get video title using yt-dlp.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: Bilibili video URL
|
|
||||||
cookie_file: Optional path to cookie file
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Video title or fallback name
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
cmd = ["yt-dlp", "--get-title", url]
|
|
||||||
if cookie_file:
|
|
||||||
cmd.extend(["--cookies", cookie_file])
|
|
||||||
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
*cmd,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE
|
|
||||||
)
|
|
||||||
stdout, stderr = await proc.communicate()
|
|
||||||
|
|
||||||
title = stdout.decode('utf-8').strip()
|
|
||||||
if title and 'error' not in title.lower():
|
|
||||||
return title
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Failed to get video title: {e}")
|
|
||||||
|
|
||||||
return "video"
|
|
||||||
|
|
||||||
def _sanitize_filename(self, title: str, bvid: str) -> str:
|
|
||||||
"""Generate standardized filename for video download.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
title: Video title (unused, kept for API compatibility)
|
|
||||||
bvid: Video BV号
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Standardized filename in format "bilibili_{bvid}"
|
|
||||||
"""
|
|
||||||
# Use standardized naming convention: platform_bvid
|
|
||||||
return f"bilibili_{bvid}"
|
|
||||||
|
|
||||||
def _create_cookie_file(self, cookie: str) -> str:
|
|
||||||
"""Create Netscape format cookie file for yt-dlp.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cookie: Cookie string (e.g., "SESSDATA=xxx; bili_jct=xxx")
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to temporary cookie file
|
|
||||||
"""
|
|
||||||
# Parse cookie string
|
|
||||||
cookies = {}
|
|
||||||
for item in cookie.split(';'):
|
|
||||||
item = item.strip()
|
|
||||||
if '=' in item:
|
|
||||||
key, value = item.split('=', 1)
|
|
||||||
cookies[key.strip()] = value.strip()
|
|
||||||
|
|
||||||
# Create Netscape format
|
|
||||||
cookie_content = "# Netscape HTTP Cookie File\n"
|
|
||||||
for name, value in cookies.items():
|
|
||||||
cookie_content += f".bilibili.com\tTRUE\t/\tFALSE\t0\t{name}\t{value}\n"
|
|
||||||
|
|
||||||
# Write to temp file
|
|
||||||
fd, path = tempfile.mkstemp(suffix='.txt')
|
|
||||||
with os.fdopen(fd, 'w') as f:
|
|
||||||
f.write(cookie_content)
|
|
||||||
|
|
||||||
return path
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
"""Zhihu Platform Adapter
|
|
||||||
|
|
||||||
Provides content extraction for Zhihu platform:
|
|
||||||
- Answer extraction from question/answer URLs
|
|
||||||
- Zhuanlan (article) extraction from zhuanlan URLs
|
|
||||||
- Cookie-based authentication
|
|
||||||
- Login page detection for expired cookies
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
from datetime import datetime
|
|
||||||
import re
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from kb_pipeline.platforms.base import PlatformAdapter
|
|
||||||
from kb_pipeline.errors import AuthError
|
|
||||||
from kb_pipeline.config import get_settings
|
|
||||||
from kb_workflow.metadata.zhihu_extractor import ZhihuPlaywrightExtractor
|
|
||||||
|
|
||||||
|
|
||||||
class ZhihuAdapter(PlatformAdapter):
|
|
||||||
"""Zhihu platform adapter for answer and zhuanlan content extraction.
|
|
||||||
|
|
||||||
Uses ZhihuPlaywrightExtractor (from kb-zhihu-metadata) for content extraction.
|
|
||||||
Requires KB_ZHIHU_COOKIE environment variable for authentication.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
adapter = ZhihuAdapter()
|
|
||||||
content_type = adapter.get_content_type("https://www.zhihu.com/question/123/answer/456")
|
|
||||||
# -> "answer"
|
|
||||||
|
|
||||||
metadata = await adapter.fetch_metadata("456", "answer")
|
|
||||||
content = await adapter.fetch_content("456", "answer")
|
|
||||||
"""
|
|
||||||
|
|
||||||
PLATFORM = "zhihu"
|
|
||||||
|
|
||||||
# URL patterns for content type detection
|
|
||||||
ANSWER_PATTERN = re.compile(r'zhihu\.com/question/\d+/answer/(\d+)')
|
|
||||||
ZHUANLAN_PATTERN = re.compile(r'zhuanlan\.zhihu\.com/p/([\w\d]+)')
|
|
||||||
QUESTION_PATTERN = re.compile(r'zhihu\.com/question/(\d+)(?!/answer)')
|
|
||||||
|
|
||||||
def __init__(self, headless: bool = True, timeout: int = 30):
|
|
||||||
"""Initialize ZhihuAdapter.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
headless: Run browser in headless mode (default: True)
|
|
||||||
timeout: Page load timeout in seconds (default: 30)
|
|
||||||
"""
|
|
||||||
self.headless = headless
|
|
||||||
self.timeout = timeout
|
|
||||||
self._cookie: Optional[str] = None
|
|
||||||
self._extractor = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def cookie(self) -> str:
|
|
||||||
"""Get Zhihu cookie from settings.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Cookie string from KB_ZHIHU_COOKIE environment variable.
|
|
||||||
Returns empty string if not configured.
|
|
||||||
"""
|
|
||||||
if self._cookie is None:
|
|
||||||
settings = get_settings()
|
|
||||||
self._cookie = settings.zhihu_cookie.get_secret_value()
|
|
||||||
return self._cookie
|
|
||||||
|
|
||||||
def get_content_type(self, url: str) -> str:
|
|
||||||
"""Identify content type from URL.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: The content URL to analyze
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Content type: 'answer', 'zhuanlan', or 'unknown'
|
|
||||||
"""
|
|
||||||
if self.ANSWER_PATTERN.search(url):
|
|
||||||
return "answer"
|
|
||||||
elif self.ZHUANLAN_PATTERN.search(url):
|
|
||||||
return "zhuanlan"
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
def _get_extractor(self):
|
|
||||||
"""Get or create ZhihuPlaywrightExtractor instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ZhihuPlaywrightExtractor instance
|
|
||||||
"""
|
|
||||||
if self._extractor is None:
|
|
||||||
self._extractor = ZhihuPlaywrightExtractor(
|
|
||||||
headless=self.headless,
|
|
||||||
timeout=self.timeout,
|
|
||||||
cookie=self.cookie
|
|
||||||
)
|
|
||||||
return self._extractor
|
|
||||||
|
|
||||||
def _extract_with_playwright(self, url: str) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Extract content using Playwright-based extractor.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: Full URL to extract content from
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Extraction result dictionary or None if extraction failed
|
|
||||||
"""
|
|
||||||
extractor = self._get_extractor()
|
|
||||||
return extractor.extract(url)
|
|
||||||
|
|
||||||
def _detect_login_page(self, result: Dict[str, Any]) -> bool:
|
|
||||||
"""Detect if the result indicates a login page redirect.
|
|
||||||
|
|
||||||
This happens when the cookie has expired or is invalid.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
result: Extraction result dictionary
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if login page is detected, False otherwise
|
|
||||||
"""
|
|
||||||
if not result:
|
|
||||||
return False
|
|
||||||
|
|
||||||
title = result.get("title", "")
|
|
||||||
content = result.get("content", "")
|
|
||||||
content_markdown = result.get("content_markdown", "")
|
|
||||||
|
|
||||||
# Check for login-related indicators
|
|
||||||
login_indicators = ["登录", "登陆", "sign in", "login"]
|
|
||||||
title_lower = title.lower()
|
|
||||||
|
|
||||||
# If title contains login keywords
|
|
||||||
if any(indicator in title_lower for indicator in login_indicators):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# If content is suspiciously short (login page has minimal content)
|
|
||||||
total_content = content + content_markdown
|
|
||||||
if len(total_content.strip()) < 100:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def fetch_metadata(self, content_id: str, content_type: str, source_url: str = None) -> Dict[str, Any]:
|
|
||||||
"""Fetch metadata for the content.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content_id: Answer ID or article ID
|
|
||||||
content_type: 'answer' or 'zhuanlan'
|
|
||||||
source_url: Original source URL (optional, used for answers to get question ID)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary containing metadata (title, author, publish_time, etc.)
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
AuthError: If cookie is missing or expired
|
|
||||||
"""
|
|
||||||
# Check for missing cookie
|
|
||||||
if not self.cookie:
|
|
||||||
raise AuthError(
|
|
||||||
step_name="zhihu_metadata",
|
|
||||||
input_summary=f"content_id={content_id}, type={content_type}",
|
|
||||||
auth_type="zhihu_cookie",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build URL based on content type
|
|
||||||
if content_type == "answer":
|
|
||||||
# For answers, try to use source_url first, then fallback to constructed URL
|
|
||||||
if source_url:
|
|
||||||
url = source_url
|
|
||||||
else:
|
|
||||||
# Fallback: use answer ID only (may not work for all cases)
|
|
||||||
url = f"https://www.zhihu.com/question/0/answer/{content_id}"
|
|
||||||
elif content_type == "zhuanlan":
|
|
||||||
url = f"https://zhuanlan.zhihu.com/p/{content_id}"
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Unsupported content type: {content_type}")
|
|
||||||
|
|
||||||
# Extract content in a separate thread to avoid blocking asyncio
|
|
||||||
result = await asyncio.to_thread(self._extract_with_playwright, url)
|
|
||||||
|
|
||||||
# Check for login page
|
|
||||||
if self._detect_login_page(result):
|
|
||||||
raise AuthError(
|
|
||||||
step_name="zhihu_metadata",
|
|
||||||
input_summary=f"content_id={content_id}, type={content_type}",
|
|
||||||
auth_type="zhihu_cookie",
|
|
||||||
cause=Exception("Cookie may have expired, please update KB_ZHIHU_COOKIE")
|
|
||||||
)
|
|
||||||
|
|
||||||
if not result:
|
|
||||||
raise AuthError(
|
|
||||||
step_name="zhihu_metadata",
|
|
||||||
input_summary=f"content_id={content_id}, type={content_type}",
|
|
||||||
auth_type="zhihu_cookie",
|
|
||||||
cause=Exception("Failed to extract content, check if content exists")
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def fetch_content(self, content_id: str, content_type: str, source_url: str = None) -> str:
|
|
||||||
"""Fetch content in Markdown format.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content_id: Answer ID or article ID
|
|
||||||
content_type: 'answer' or 'zhuanlan'
|
|
||||||
source_url: Original source URL (optional, used for answers)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Markdown-formatted content with YAML frontmatter
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
AuthError: If cookie is missing or expired
|
|
||||||
"""
|
|
||||||
# Get metadata (which also validates auth)
|
|
||||||
metadata = await self.fetch_metadata(content_id, content_type, source_url=source_url)
|
|
||||||
|
|
||||||
# Get content markdown
|
|
||||||
content_markdown = metadata.get("content_markdown", "") or metadata.get("content", "")
|
|
||||||
|
|
||||||
# Format with YAML frontmatter
|
|
||||||
return self._format_with_frontmatter(metadata, content_markdown, content_type)
|
|
||||||
|
|
||||||
def _format_with_frontmatter(
|
|
||||||
self,
|
|
||||||
metadata: Dict[str, Any],
|
|
||||||
content: str,
|
|
||||||
content_type: str
|
|
||||||
) -> str:
|
|
||||||
"""Format content with YAML frontmatter.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
metadata: Content metadata dictionary
|
|
||||||
content: Markdown content
|
|
||||||
content_type: 'answer' or 'zhuanlan'
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted string with YAML frontmatter and content
|
|
||||||
"""
|
|
||||||
frontmatter_lines = [
|
|
||||||
"---",
|
|
||||||
f"title: {metadata.get('title', '')}",
|
|
||||||
f"author: {metadata.get('author', '')}",
|
|
||||||
f"source_url: {metadata.get('url', '')}",
|
|
||||||
f"platform: {self.PLATFORM}",
|
|
||||||
f"content_type: {content_type}",
|
|
||||||
f"fetched_at: {datetime.now().isoformat()}",
|
|
||||||
"---",
|
|
||||||
"",
|
|
||||||
]
|
|
||||||
|
|
||||||
return "\n".join(frontmatter_lines) + content
|
|
||||||
|
|
||||||
def get_output_filename(self, content_id: str, content_type: str, suffix: str) -> str:
|
|
||||||
"""Generate output filename for the content.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content_id: Answer ID or article ID
|
|
||||||
content_type: 'answer' or 'zhuanlan'
|
|
||||||
suffix: File suffix (e.g., 'metadata.json', 'content.md')
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Filename string (e.g., 'zhihu_789012_metadata.json')
|
|
||||||
"""
|
|
||||||
return f"zhihu_{content_id}_{suffix}"
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
"""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 .base import BaseProcessor
|
|
||||||
from .parse import ParseProcessor
|
|
||||||
from .download import DownloadProcessor
|
|
||||||
from .asr import ASRProcessor
|
|
||||||
from .analyze import AnalyzeProcessor
|
|
||||||
from .archive import ArchiveProcessor
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"BaseProcessor",
|
|
||||||
"ParseProcessor",
|
|
||||||
"DownloadProcessor",
|
|
||||||
"ASRProcessor",
|
|
||||||
"AnalyzeProcessor",
|
|
||||||
"ArchiveProcessor",
|
|
||||||
]
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
"""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
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional, Dict, Any
|
|
||||||
|
|
||||||
# Add kb-deep-analyzer to path for import
|
|
||||||
_kb_deep_analyzer_path = Path(__file__).parent.parent.parent / "kb-deep-analyzer"
|
|
||||||
if str(_kb_deep_analyzer_path) not in sys.path:
|
|
||||||
sys.path.insert(0, str(_kb_deep_analyzer_path))
|
|
||||||
|
|
||||||
from ..config import get_settings
|
|
||||||
from ..schemas import AnalyzeInput, AnalyzeOutput
|
|
||||||
from ..state import WorkflowStateManager
|
|
||||||
from .base import BaseProcessor
|
|
||||||
|
|
||||||
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 对象
|
|
||||||
"""
|
|
||||||
# Lazy import to avoid dependency issues
|
|
||||||
from deep_analyzer import DeepAnalyzer, SimpleAIClient
|
|
||||||
|
|
||||||
# 初始化 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 实例
|
|
||||||
"""
|
|
||||||
from deep_analyzer import 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: 内容类型
|
|
||||||
"""
|
|
||||||
from deep_analyzer import generate_frontmatter
|
|
||||||
|
|
||||||
# 标准化元数据(扁平化嵌套结构)
|
|
||||||
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}")
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
"""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 ..config import get_settings
|
|
||||||
from ..schemas import ArchiveInput, ArchiveOutput
|
|
||||||
from ..state import WorkflowStateManager
|
|
||||||
from .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')
|
|
||||||
@@ -1,685 +0,0 @@
|
|||||||
"""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 ..config import get_settings
|
|
||||||
from ..schemas import ASRInput, ASROutput
|
|
||||||
from ..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
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
"""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_pipeline.config import get_settings
|
|
||||||
from kb_pipeline.schemas import StepInput, StepOutput
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from kb_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
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
"""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_pipeline.schemas import DownloadInput, DownloadOutput
|
|
||||||
from .base import BaseProcessor
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from kb_pipeline.state import WorkflowStateManager
|
|
||||||
from kb_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_pipeline.platforms.bilibili import BilibiliAdapter
|
|
||||||
return BilibiliAdapter(state_manager=self.state_manager)
|
|
||||||
elif platform == "zhihu":
|
|
||||||
from kb_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
|
|
||||||
"""
|
|
||||||
if platform == "bilibili":
|
|
||||||
# Bilibili metadata structure: {metadata: {mid: ...}, ...}
|
|
||||||
if "metadata" in metadata:
|
|
||||||
return str(metadata["metadata"].get("mid", ""))
|
|
||||||
return str(metadata.get("mid", ""))
|
|
||||||
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
|
|
||||||
)
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
"""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_pipeline.config import get_settings
|
|
||||||
from kb_pipeline.link_parser import LinkParser
|
|
||||||
from kb_pipeline.schemas import ParseInput, ParseOutput
|
|
||||||
from .base import BaseProcessor
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from kb_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
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
"""Retry mechanism for KB Pipeline
|
|
||||||
|
|
||||||
Provides automatic retry for network errors using tenacity library.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from tenacity import (
|
|
||||||
retry,
|
|
||||||
stop_after_attempt,
|
|
||||||
wait_exponential,
|
|
||||||
retry_if_exception_type,
|
|
||||||
before_sleep_log,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .errors import NetworkError
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
retry_on_network_error = retry(
|
|
||||||
stop=stop_after_attempt(3),
|
|
||||||
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
||||||
retry=retry_if_exception_type(NetworkError),
|
|
||||||
before_sleep=before_sleep_log(logger, logging.WARNING),
|
|
||||||
reraise=True,
|
|
||||||
)
|
|
||||||
"""Decorator for automatic retry on network errors
|
|
||||||
|
|
||||||
Automatically retries functions that raise NetworkError up to 3 times
|
|
||||||
with exponential backoff (2s, 4s, up to max 10s).
|
|
||||||
|
|
||||||
Example:
|
|
||||||
@retry_on_network_error
|
|
||||||
def fetch_data(url):
|
|
||||||
response = requests.get(url)
|
|
||||||
if response.status_code >= 500:
|
|
||||||
raise NetworkError(
|
|
||||||
step_name="fetch",
|
|
||||||
input_summary=f"url={url}",
|
|
||||||
status_code=response.status_code
|
|
||||||
)
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
Args:
|
|
||||||
func: Function to wrap with retry logic
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Wrapped function with retry logic
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
NetworkError: After 3 failed attempts
|
|
||||||
"""
|
|
||||||
@@ -1,446 +0,0 @@
|
|||||||
"""Core Pipeline Schemas
|
|
||||||
|
|
||||||
定义工作流管道的核心 Schema,包括 StepInput、StepOutput、StepResult、
|
|
||||||
StepStatus、StepRecord、WorkflowState 和 StepDefinition。
|
|
||||||
使用 Pydantic v2 确保类型安全和自动验证。
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from typing import Optional, Dict, Literal, Any, List
|
|
||||||
from datetime import datetime
|
|
||||||
from enum import Enum
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
class StepStatus(str, Enum):
|
|
||||||
"""步骤执行状态枚举"""
|
|
||||||
PENDING = "pending"
|
|
||||||
RUNNING = "running"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
FAILED = "failed"
|
|
||||||
SKIPPED = "skipped"
|
|
||||||
|
|
||||||
|
|
||||||
class StepInput(BaseModel):
|
|
||||||
"""所有步骤输入的基类
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
content_id: 内容唯一标识,如 B站 BV号、知乎回答ID等
|
|
||||||
platform: 平台名称,限定为 bilibili 或 zhihu
|
|
||||||
"""
|
|
||||||
content_id: str = Field(..., min_length=1, description="内容唯一标识")
|
|
||||||
platform: Literal["bilibili", "zhihu"] = Field(..., description="平台名称")
|
|
||||||
|
|
||||||
|
|
||||||
class StepOutput(BaseModel):
|
|
||||||
"""所有步骤输出的基类
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
success: 步骤是否成功执行
|
|
||||||
error_message: 错误信息,成功时为 None
|
|
||||||
completed_at: 完成时间,默认为当前时间
|
|
||||||
"""
|
|
||||||
success: bool = Field(..., description="步骤是否成功")
|
|
||||||
error_message: Optional[str] = Field(None, description="错误信息")
|
|
||||||
completed_at: datetime = Field(default_factory=datetime.now, description="完成时间")
|
|
||||||
|
|
||||||
|
|
||||||
class StepResult(BaseModel):
|
|
||||||
"""步骤执行结果
|
|
||||||
|
|
||||||
包含步骤的输入、输出和执行状态。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
step_name: 步骤名称
|
|
||||||
input_data: 步骤输入数据
|
|
||||||
output_data: 步骤输出数据,可能为 None
|
|
||||||
status: 步骤执行状态
|
|
||||||
"""
|
|
||||||
step_name: str = Field(..., description="步骤名称")
|
|
||||||
input_data: StepInput = Field(..., description="步骤输入数据")
|
|
||||||
output_data: Optional[StepOutput] = Field(None, description="步骤输出数据")
|
|
||||||
status: StepStatus = Field(default=StepStatus.PENDING, description="步骤执行状态")
|
|
||||||
|
|
||||||
|
|
||||||
class StepRecord(BaseModel):
|
|
||||||
"""单个步骤的执行记录
|
|
||||||
|
|
||||||
记录步骤执行的状态、时间和结果摘要。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
step_name: 步骤名称
|
|
||||||
status: 步骤执行状态
|
|
||||||
started_at: 开始时间
|
|
||||||
completed_at: 完成时间
|
|
||||||
error_message: 错误信息
|
|
||||||
output_summary: 输出摘要
|
|
||||||
"""
|
|
||||||
step_name: str = Field(..., description="步骤名称")
|
|
||||||
status: StepStatus = Field(..., description="步骤执行状态")
|
|
||||||
started_at: Optional[datetime] = Field(None, description="开始时间")
|
|
||||||
completed_at: Optional[datetime] = Field(None, description="完成时间")
|
|
||||||
error_message: Optional[str] = Field(None, description="错误信息")
|
|
||||||
output_summary: Optional[str] = Field(None, description="输出摘要")
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowState(BaseModel):
|
|
||||||
"""工作流状态 - 存储在 workflow_state.json
|
|
||||||
|
|
||||||
管理整个工作流的状态,包括内容信息、执行历史和步骤状态。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
schema_version: Schema 版本号
|
|
||||||
content_id: 内容唯一标识
|
|
||||||
platform: 平台名称
|
|
||||||
source_url: 原始链接
|
|
||||||
created_at: 创建时间
|
|
||||||
updated_at: 更新时间
|
|
||||||
steps: 步骤执行记录字典
|
|
||||||
"""
|
|
||||||
schema_version: str = Field(default="1.0", description="Schema 版本号")
|
|
||||||
content_id: str = Field(..., description="内容唯一标识")
|
|
||||||
platform: str = Field(..., description="平台名称")
|
|
||||||
source_url: str = Field(..., description="原始链接")
|
|
||||||
created_at: datetime = Field(default_factory=datetime.now, description="创建时间")
|
|
||||||
updated_at: datetime = Field(default_factory=datetime.now, description="更新时间")
|
|
||||||
steps: Dict[str, StepRecord] = Field(default_factory=dict, description="步骤执行记录")
|
|
||||||
|
|
||||||
def is_step_completed(self, step_name: str) -> bool:
|
|
||||||
"""检查指定步骤是否已完成
|
|
||||||
|
|
||||||
Args:
|
|
||||||
step_name: 步骤名称
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
如果步骤存在且状态为 COMPLETED 返回 True,否则返回 False
|
|
||||||
"""
|
|
||||||
record = self.steps.get(step_name)
|
|
||||||
return record is not None and record.status == StepStatus.COMPLETED
|
|
||||||
|
|
||||||
def mark_step_started(self, step_name: str) -> None:
|
|
||||||
"""标记步骤开始执行
|
|
||||||
|
|
||||||
Args:
|
|
||||||
step_name: 步骤名称
|
|
||||||
"""
|
|
||||||
self.steps[step_name] = StepRecord(
|
|
||||||
step_name=step_name,
|
|
||||||
status=StepStatus.RUNNING,
|
|
||||||
started_at=datetime.now()
|
|
||||||
)
|
|
||||||
self.updated_at = datetime.now()
|
|
||||||
|
|
||||||
def mark_step_completed(self, step_name: str, output_summary: Optional[str] = None) -> None:
|
|
||||||
"""标记步骤完成
|
|
||||||
|
|
||||||
Args:
|
|
||||||
step_name: 步骤名称
|
|
||||||
output_summary: 输出摘要(可选)
|
|
||||||
"""
|
|
||||||
if step_name in self.steps:
|
|
||||||
self.steps[step_name].status = StepStatus.COMPLETED
|
|
||||||
self.steps[step_name].completed_at = datetime.now()
|
|
||||||
self.steps[step_name].output_summary = output_summary
|
|
||||||
else:
|
|
||||||
self.steps[step_name] = StepRecord(
|
|
||||||
step_name=step_name,
|
|
||||||
status=StepStatus.COMPLETED,
|
|
||||||
completed_at=datetime.now(),
|
|
||||||
output_summary=output_summary
|
|
||||||
)
|
|
||||||
self.updated_at = datetime.now()
|
|
||||||
|
|
||||||
def mark_step_failed(self, step_name: str, error_message: str) -> None:
|
|
||||||
"""标记步骤失败
|
|
||||||
|
|
||||||
Args:
|
|
||||||
step_name: 步骤名称
|
|
||||||
error_message: 错误信息
|
|
||||||
"""
|
|
||||||
if step_name in self.steps:
|
|
||||||
self.steps[step_name].status = StepStatus.FAILED
|
|
||||||
self.steps[step_name].error_message = error_message
|
|
||||||
self.steps[step_name].completed_at = datetime.now()
|
|
||||||
else:
|
|
||||||
self.steps[step_name] = StepRecord(
|
|
||||||
step_name=step_name,
|
|
||||||
status=StepStatus.FAILED,
|
|
||||||
error_message=error_message,
|
|
||||||
completed_at=datetime.now()
|
|
||||||
)
|
|
||||||
self.updated_at = datetime.now()
|
|
||||||
|
|
||||||
|
|
||||||
class StepDefinition(BaseModel):
|
|
||||||
"""步骤定义 - 描述步骤的前置条件、输出格式、成功标准
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
step_name: 步骤名称
|
|
||||||
description: 步骤描述
|
|
||||||
input_schema: 输入 Schema 类型
|
|
||||||
output_schema: 输出 Schema 类型
|
|
||||||
dependencies: 依赖的步骤列表
|
|
||||||
"""
|
|
||||||
step_name: str = Field(..., description="步骤名称")
|
|
||||||
description: str = Field(..., description="步骤描述")
|
|
||||||
input_schema: type[StepInput] = Field(..., description="输入 Schema 类型")
|
|
||||||
output_schema: type[StepOutput] = Field(..., description="输出 Schema 类型")
|
|
||||||
dependencies: list[str] = Field(default_factory=list, description="依赖的步骤列表")
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# ASR Schemas (Phase 3)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
class ASRInput(StepInput):
|
|
||||||
"""ASR 步骤输入
|
|
||||||
|
|
||||||
继承 StepInput 的基础字段,添加视频路径和内容类型。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
video_path: 视频文件路径(可能为空,用于 Whisper 降级)
|
|
||||||
content_type: 内容类型,默认为 'video'
|
|
||||||
content_dir: 内容目录路径,用于输出字幕文件
|
|
||||||
"""
|
|
||||||
video_path: str = Field(default="", description="视频文件路径(可能为空)")
|
|
||||||
content_type: str = Field(default="video", description="内容类型")
|
|
||||||
content_dir: str = Field(..., description="内容目录路径")
|
|
||||||
|
|
||||||
|
|
||||||
class ASROutput(StepOutput):
|
|
||||||
"""ASR 步骤输出
|
|
||||||
|
|
||||||
继承 StepOutput 的基础字段,添加 ASR 特定字段。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
tool: 使用的工具 (subtitle, whisper, whisper-diarization)
|
|
||||||
has_subtitle: 是否有官方字幕
|
|
||||||
output_files: 输出文件路径字典 {txt, srt, json}
|
|
||||||
segments: 带时间戳的分段列表
|
|
||||||
text: 完整文本
|
|
||||||
duration_seconds: 音频时长
|
|
||||||
speakers: 说话者列表(仅说话者分离时有值)
|
|
||||||
"""
|
|
||||||
tool: Literal["subtitle", "whisper", "whisper-diarization"] = Field(
|
|
||||||
..., description="使用的工具"
|
|
||||||
)
|
|
||||||
has_subtitle: bool = Field(..., description="是否有官方字幕")
|
|
||||||
output_files: Dict[str, str] = Field(
|
|
||||||
default_factory=dict, description="输出文件路径 {txt, srt, json}"
|
|
||||||
)
|
|
||||||
segments: List[Dict[str, Any]] = Field(
|
|
||||||
default_factory=list, description="带时间戳的分段列表"
|
|
||||||
)
|
|
||||||
text: str = Field(default="", description="完整文本")
|
|
||||||
duration_seconds: float = Field(default=0.0, description="音频时长")
|
|
||||||
speakers: Optional[List[str]] = Field(
|
|
||||||
None, description="说话者列表(仅说话者分离时有值)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Parse Schemas (Phase 04)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class ParseInput(StepInput):
|
|
||||||
"""Parse 步骤输入
|
|
||||||
|
|
||||||
继承 StepInput 的基础字段,添加 URL 字段。
|
|
||||||
content_id 和 platform 在解析前是空的,解析后会被填充。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
url: 要解析的 URL
|
|
||||||
content_id: 解析后填充的内容 ID(初始为空)
|
|
||||||
platform: 解析后填充的平台名称(初始为空)
|
|
||||||
"""
|
|
||||||
url: str = Field(..., min_length=1, description="URL to parse")
|
|
||||||
content_id: str = Field(default="", description="Will be populated after parsing")
|
|
||||||
platform: str = Field(default="", description="Will be populated after parsing")
|
|
||||||
|
|
||||||
|
|
||||||
class ParseOutput(StepOutput):
|
|
||||||
"""Parse 步骤输出
|
|
||||||
|
|
||||||
继承 StepOutput 的基础字段,添加解析特定字段。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
platform: 检测到的平台 (bilibili, zhihu)
|
|
||||||
content_type: 内容类型 (video, article, answer, zhuanlan)
|
|
||||||
content_id: 提取的内容 ID
|
|
||||||
resolved_url: 短链解析后的完整 URL
|
|
||||||
original_url: 用户提供的原始 URL
|
|
||||||
"""
|
|
||||||
platform: str = Field(..., description="Detected platform")
|
|
||||||
content_type: str = Field(..., description="Content type (video, article, etc.)")
|
|
||||||
content_id: str = Field(..., description="Extracted content ID")
|
|
||||||
resolved_url: str = Field(..., description="Resolved URL after short link expansion")
|
|
||||||
original_url: str = Field(..., description="Original URL provided by user")
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Download Schemas (Phase 04)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadInput(StepInput):
|
|
||||||
"""Download 步骤输入
|
|
||||||
|
|
||||||
继承 StepInput 的基础字段,添加下载特定字段。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
content_dir: 保存下载内容的目录路径
|
|
||||||
content_type: 内容类型 (video, article, answer, zhuanlan)
|
|
||||||
source_url: 原始 URL(可选,用于知乎回答等需要完整 URL 的情况)
|
|
||||||
"""
|
|
||||||
content_dir: str = Field(..., min_length=1, description="Directory to save downloaded content")
|
|
||||||
content_type: str = Field(..., min_length=1, description="Content type (video, article, answer, zhuanlan)")
|
|
||||||
source_url: Optional[str] = Field(None, description="Original source URL")
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadOutput(StepOutput):
|
|
||||||
"""Download 步骤输出
|
|
||||||
|
|
||||||
继承 StepOutput 的基础字段,添加下载特定字段。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
metadata_file: 元数据 JSON 文件路径
|
|
||||||
content_file: 内容文件路径 (视频/音频或文本)
|
|
||||||
title: 内容标题
|
|
||||||
author: 内容作者
|
|
||||||
duration: 视频内容的时长(秒),仅视频有值
|
|
||||||
"""
|
|
||||||
metadata_file: str = Field(..., description="Path to metadata JSON file")
|
|
||||||
content_file: str = Field(..., description="Path to content file (video/audio or text)")
|
|
||||||
title: str = Field(..., description="Content title")
|
|
||||||
author: str = Field(..., description="Content author")
|
|
||||||
duration: Optional[int] = Field(None, description="Duration for video content in seconds")
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Analyze Schemas (Phase 04)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class AnalyzeInput(StepInput):
|
|
||||||
"""Analyze 步骤输入
|
|
||||||
|
|
||||||
继承 StepInput 的基础字段,添加分析特定字段。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
metadata_file: 元数据 JSON 文件路径
|
|
||||||
content_file: 内容文件路径(字幕 JSON 或文章内容)
|
|
||||||
content_type: 内容类型(video 或 article)
|
|
||||||
output_file: 输出分析 Markdown 文件路径
|
|
||||||
"""
|
|
||||||
metadata_file: str = Field(..., min_length=1, description="Path to metadata JSON file")
|
|
||||||
content_file: str = Field(..., min_length=1, description="Path to content file (subtitle JSON or article content)")
|
|
||||||
content_type: str = Field(..., min_length=1, description="Content type (video or article)")
|
|
||||||
output_file: str = Field(..., min_length=1, description="Path to output analysis Markdown file")
|
|
||||||
|
|
||||||
|
|
||||||
class AnalyzeOutput(StepOutput):
|
|
||||||
"""Analyze 步骤输出
|
|
||||||
|
|
||||||
继承 StepOutput 的基础字段,添加分析特定字段。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
analysis_file: 生成的分析报告文件路径
|
|
||||||
summary: 内容摘要
|
|
||||||
main_topics: 提取的主要主题列表
|
|
||||||
key_insights: 关键见解列表
|
|
||||||
tags: 推荐标签列表
|
|
||||||
stats: 分析统计信息字典
|
|
||||||
"""
|
|
||||||
analysis_file: str = Field(..., description="Path to generated analysis report")
|
|
||||||
summary: str = Field(default="", description="Content summary")
|
|
||||||
main_topics: List[str] = Field(default_factory=list, description="Main topics extracted")
|
|
||||||
key_insights: List[str] = Field(default_factory=list, description="Key insights")
|
|
||||||
tags: List[str] = Field(default_factory=list, description="Recommended tags")
|
|
||||||
stats: Dict[str, Any] = Field(default_factory=dict, description="Statistics about analysis")
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Pipeline Schemas (Phase 04-04)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineInput(BaseModel):
|
|
||||||
"""Pipeline input - URL to process
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
url: URL to process (required)
|
|
||||||
output_dir: Optional output directory for content
|
|
||||||
skip_completed: Skip already completed steps (default True)
|
|
||||||
"""
|
|
||||||
url: str = Field(..., min_length=1, description="URL to process")
|
|
||||||
output_dir: Optional[Path] = Field(None, description="Output directory for content")
|
|
||||||
skip_completed: bool = Field(default=True, description="Skip already completed steps")
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineOutput(BaseModel):
|
|
||||||
"""Pipeline output - results from all steps
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
success: Whether the pipeline completed successfully
|
|
||||||
error_message: Error message if pipeline failed
|
|
||||||
results: Dictionary of results from each step
|
|
||||||
content_dir: Path to the content directory
|
|
||||||
"""
|
|
||||||
success: bool = Field(..., description="Whether the pipeline completed successfully")
|
|
||||||
error_message: Optional[str] = Field(None, description="Error message if pipeline failed")
|
|
||||||
results: Dict[str, Any] = Field(default_factory=dict, description="Results from each step")
|
|
||||||
content_dir: Optional[Path] = Field(None, description="Path to the content directory")
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineConfig(BaseModel):
|
|
||||||
"""Pipeline configuration
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
steps: Steps to execute in order (default: parse, download, asr, analyze, archive)
|
|
||||||
output_dir: Base output directory
|
|
||||||
skip_completed: Skip completed steps (default True)
|
|
||||||
max_retries: Max retries per step (default 3)
|
|
||||||
"""
|
|
||||||
steps: List[str] = Field(
|
|
||||||
default_factory=lambda: ["parse", "download", "asr", "analyze", "archive"],
|
|
||||||
description="Steps to execute in order"
|
|
||||||
)
|
|
||||||
output_dir: Optional[Path] = Field(None, description="Base output directory")
|
|
||||||
skip_completed: bool = Field(default=True, description="Skip completed steps")
|
|
||||||
max_retries: int = Field(default=3, description="Max retries per step")
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Archive Schemas (Phase 5)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
class ArchiveInput(StepInput):
|
|
||||||
"""Archive 步骤输入
|
|
||||||
|
|
||||||
继承 StepInput 的基础字段,添加归档特定字段。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
content_dir: 内容目录路径(中间目录)
|
|
||||||
analysis_file: 分析报告 md 文件路径
|
|
||||||
content_type: 内容类型
|
|
||||||
"""
|
|
||||||
content_dir: str = Field(..., min_length=1, description="Content directory path (intermediate)")
|
|
||||||
analysis_file: str = Field(..., min_length=1, description="Path to analysis Markdown file")
|
|
||||||
content_type: str = Field(..., min_length=1, description="Content type (video, article, etc.)")
|
|
||||||
|
|
||||||
|
|
||||||
class ArchiveOutput(StepOutput):
|
|
||||||
"""Archive 步骤输出
|
|
||||||
|
|
||||||
继承 StepOutput 的基础字段,添加归档特定字段。
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
archive_file: 归档后的文件路径
|
|
||||||
skipped: 是否跳过(已有相同文件)
|
|
||||||
platform: 平台名称
|
|
||||||
"""
|
|
||||||
archive_file: str = Field(default="", description="Path to archived file")
|
|
||||||
skipped: bool = Field(default=False, description="Whether archiving was skipped")
|
|
||||||
platform: str = Field(default="", description="Platform name")
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
"""
|
|
||||||
WorkflowStateManager - 步骤级状态持久化管理器
|
|
||||||
|
|
||||||
提供工作流状态的原子写入、备份轮转和崩溃恢复功能。
|
|
||||||
|
|
||||||
Purpose: 为工作流提供可靠的状态管理,支持断点续传和崩溃恢复
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from kb_pipeline.schemas import WorkflowState
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowStateManager:
|
|
||||||
"""工作流状态管理器
|
|
||||||
|
|
||||||
管理工作流状态的持久化,包括:
|
|
||||||
- 原子写入(使用临时文件 + os.replace)
|
|
||||||
- 备份轮转(workflow_state.json -> workflow_state.json.bak)
|
|
||||||
- 主文件损坏时从备份恢复
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
STATE_FILENAME: 状态文件名
|
|
||||||
content_dir: 内容目录路径
|
|
||||||
state_path: 状态文件完整路径
|
|
||||||
backup_path: 备份文件完整路径
|
|
||||||
"""
|
|
||||||
|
|
||||||
STATE_FILENAME = "workflow_state.json"
|
|
||||||
|
|
||||||
def __init__(self, content_dir: Path):
|
|
||||||
"""初始化状态管理器
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content_dir: 内容目录路径,状态文件将保存在此目录下
|
|
||||||
"""
|
|
||||||
self.content_dir = Path(content_dir)
|
|
||||||
self.state_path = self.content_dir / self.STATE_FILENAME
|
|
||||||
self.backup_path = self.content_dir / f"{self.STATE_FILENAME}.bak"
|
|
||||||
|
|
||||||
def load(self) -> Optional[WorkflowState]:
|
|
||||||
"""加载工作流状态
|
|
||||||
|
|
||||||
加载策略:
|
|
||||||
1. 尝试加载主文件 (workflow_state.json)
|
|
||||||
2. 如果主文件损坏或不存在,尝试加载备份文件
|
|
||||||
3. 如果都不存在,返回 None
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
WorkflowState 实例,如果文件不存在则返回 None
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: 如果文件格式无效且备份也无法恢复
|
|
||||||
"""
|
|
||||||
def _try_load(path: Path) -> Optional[WorkflowState]:
|
|
||||||
"""尝试从指定路径加载状态"""
|
|
||||||
if not path.exists():
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
return WorkflowState.model_validate(data)
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
# 先尝试主文件
|
|
||||||
state = _try_load(self.state_path)
|
|
||||||
if state is not None:
|
|
||||||
return state
|
|
||||||
|
|
||||||
# 主文件失败,尝试备份
|
|
||||||
state = _try_load(self.backup_path)
|
|
||||||
return state
|
|
||||||
|
|
||||||
def save(self, state: WorkflowState) -> None:
|
|
||||||
"""原子保存工作流状态
|
|
||||||
|
|
||||||
保存策略(原子写入 + 备份轮转):
|
|
||||||
1. 创建父目录(如果不存在)
|
|
||||||
2. 如果状态文件存在,将其重命名为备份文件
|
|
||||||
3. 写入临时文件
|
|
||||||
4. 原子替换(os.replace)
|
|
||||||
|
|
||||||
原子写入确保:
|
|
||||||
- 读取者永远不会看到部分写入的文件
|
|
||||||
- 崩溃时要么是旧文件,要么是新文件
|
|
||||||
|
|
||||||
Args:
|
|
||||||
state: 要保存的工作流状态
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
OSError: 文件操作失败(权限、磁盘空间等)
|
|
||||||
"""
|
|
||||||
# 1. 创建父目录
|
|
||||||
self.content_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# 2. 备份轮转:如果状态文件存在,移动到备份
|
|
||||||
if self.state_path.exists():
|
|
||||||
# 删除旧备份
|
|
||||||
if self.backup_path.exists():
|
|
||||||
self.backup_path.unlink()
|
|
||||||
# 将当前状态移动到备份
|
|
||||||
self.state_path.rename(self.backup_path)
|
|
||||||
|
|
||||||
# 3. 写入临时文件(在相同目录下,确保原子替换有效)
|
|
||||||
fd, temp_path = tempfile.mkstemp(dir=self.content_dir, suffix='.tmp')
|
|
||||||
try:
|
|
||||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
|
||||||
f.write(state.model_dump_json(indent=2))
|
|
||||||
# 4. 原子替换
|
|
||||||
os.replace(temp_path, self.state_path)
|
|
||||||
except Exception:
|
|
||||||
# 清理临时文件
|
|
||||||
if os.path.exists(temp_path):
|
|
||||||
os.unlink(temp_path)
|
|
||||||
raise
|
|
||||||
|
|
||||||
def get_or_create(
|
|
||||||
self,
|
|
||||||
content_id: str,
|
|
||||||
platform: str,
|
|
||||||
source_url: str
|
|
||||||
) -> WorkflowState:
|
|
||||||
"""获取现有状态或创建新状态
|
|
||||||
|
|
||||||
幂等操作:如果状态已存在,返回现有状态;否则创建新状态。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content_id: 内容唯一标识
|
|
||||||
platform: 平台名称
|
|
||||||
source_url: 原始链接
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
WorkflowState 实例
|
|
||||||
"""
|
|
||||||
existing = self.load()
|
|
||||||
if existing is not None:
|
|
||||||
return existing
|
|
||||||
|
|
||||||
# 创建新状态
|
|
||||||
return WorkflowState(
|
|
||||||
content_id=content_id,
|
|
||||||
platform=platform,
|
|
||||||
source_url=source_url
|
|
||||||
)
|
|
||||||
Reference in New Issue
Block a user