自动同步: 2026-03-18 02:10

This commit is contained in:
2026-03-18 02:10:03 +08:00
parent 19b4b85777
commit 7206fcd730
75 changed files with 7528 additions and 3055 deletions
+14
View File
@@ -0,0 +1,14 @@
# KB Workflow 环境变量配置
export KB_BASE_PATH="~/work/knowledge-base"
# 可选: B站 Cookie(用于下载会员视频)
# export BILIBILI_COOKIE="SESSDATA=xxx; bili_jct=xxx"
# 可选: AI 分析配置
# export AI_API_KEY="your-api-key"
# export AI_PROVIDER="openai" # 或 zhipu, minimax
# export AI_MODEL="gpt-4"
# 可选: Git 配置
# export KB_GIT_USER="Your Name"
# export KB_GIT_EMAIL="your@email.com"
+14
View File
@@ -0,0 +1,14 @@
# KB Workflow 环境变量配置
export KB_BASE_PATH="~/work/knowledge-base"
# 可选: B站 Cookie(用于下载会员视频)
# export BILIBILI_COOKIE="SESSDATA=xxx; bili_jct=xxx"
# 可选: AI 分析配置
# export AI_API_KEY="your-api-key"
# export AI_PROVIDER="openai" # 或 zhipu, minimax
# export AI_MODEL="gpt-4"
# 可选: Git 配置
# export KB_GIT_USER="Your Name"
# export KB_GIT_EMAIL="your@email.com"
+190
View File
@@ -0,0 +1,190 @@
---
name: kb-workflow
description: 当收到"添加到知识库 + 链接" 或"保存到知识库 + 链接"时使用
---
# KB Workflow
知识库内容处理管道,支持 B站、知乎等平台的内容抓取、语音识别、深度分析和归档。
## 什么时候使用
当用户发送包含以下模式的消息时,此技能自动触发:
| 触发词 | 行为 | 示例 |
|--------|------|------|
| `添加到知识库` + 链接 | 添加到知识库并处理 | "添加到知识库 https://www.bilibili.com/video/BVxxx" |
| `保存到知识库` + 链接 | 同上 | "保存到知识库 https://zhuanlan.zhihu.com/p/xxx" |
## 环境变量
### 执行前必须检查
在执行 workflow 之前,必须检查环境变量是否已经设置。
**必须的环境变量**(缺失则报错退出):
```bash
# ~/.kb_env 文件
export AI_API_KEY="your-api-key"
export AI_PROVIDER="zhipu"
export AI_MODEL="glm-5"
export AI_BASE_URL="https://open.bigmodel.cn/api/coding/paas/v4"
export KB_INTERMEDIATE_PATH="~/work/intermediate/analysis"
export KB_ARCHIVE_PATH="~/work/zq/30.资源/AI分析"
```
**可选的环境变量**:
| 变量名 | 用途 |
|--------|------|
| `BILIBILI_COOKIE` | 下载会员视频 |
| `KB_GIT_USER` / `KB_GIT_EMAIL` | Git 提交信息 |
| `HF_TOKEN` | HuggingFace Token(说话者分离) |
### 检查命令
```bash
# 从 ~/.kb_env 加载环境变量
if [ -f ~/.kb_env ]; then
source ~/.kb_env
fi
# 检查必须的环境变量
if [ -z "$AI_API_KEY" ] || [ -z "$AI_PROVIDER" ] || [ -z "$AI_MODEL" ]; then
echo "❌ 错误:必须的环境变量未设置"
echo "请配置 ~/.kb_env 文件"
exit 1
fi
echo "✅ 环境变量检查通过"
```
## Workflow 执行方式
### 处理链接
```bash
# 处理 URL(自动执行完整管道)
python kb_workflow/kb_workflow.py process "https://www.bilibili.com/video/BVxxx"
# 指定输出目录
python kb_workflow/kb_workflow.py process "https://..." -o /path/to/output
# 强制重新执行(忽略已完成状态)
python kb_workflow/kb_workflow.py process "https://..." --no-skip
```
### 知乎登录
```bash
# 二维码扫码登录(Cookie 自动保存)
python kb_workflow/kb_workflow.py login
# 检查 Cookie 状态
python kb_workflow/kb_workflow.py login --status
# 从 JSON 文件导入 Cookie
python kb_workflow/kb_workflow.py login --import-cookie cookies.json
```
**注意:** 知乎链接处理时会自动检测 Cookie 是否过期,过期则自动触发扫码登录。
## 支持的平台和内容类型
| 平台 | 内容类型 | URL 示例 |
|------|----------|----------|
| B站 | 视频 | `bilibili.com/video/BVxxx` |
| B站 | 专栏 | `bilibili.com/read/cvxxx` |
| 知乎 | 问答 | `zhihu.com/question/xxx/answer/xxx` |
| 知乎 | 专栏 | `zhuanlan.zhihu.com/p/xxx` |
## 处理流程
```
┌─────────────────────────────────────────────────────────────┐
│ Pipeline 处理流程 │
├─────────────────────────────────────────────────────────────┤
│ 1. Parse → LinkParser 解析 URL,识别平台和 ID │
│ 2. Download → 平台适配器获取元数据和内容 │
│ 3. ASR → 语音识别(视频无字幕时) │
│ 4. Analyze → DeepAnalyzer 生成深度分析报告 │
│ 5. Archive → 归档到 KB_ARCHIVE_PATH │
└─────────────────────────────────────────────────────────────┘
```
**知乎特殊处理:**
- 评论默认爬取并追加到正文末尾
- Cookie 过期时自动弹出二维码,扫码后继续执行
- 无需手动设置 Cookie
## 目录结构
**工作目录(KB_INTERMEDIATE_PATH):**
```
~/work/intermediate/analysis/
└── {platform}_{content_id}/
├── metadata.json # 元数据
├── content.md # 内容(含评论)
└── downloads/ # 下载的媒体文件
```
**归档目录(KB_ARCHIVE_PATH):**
```
~/work/zq/30.资源/AI分析/
└── YYYY-MM-DD/
└── {platform}_{id}_analysis.md # 深度分析报告
```
## ⚠️ 严格失败处理原则
> **深度分析步骤必须成功,否则整个流程失败**
1. **深度分析不可跳过**:AI 分析是必须成功执行的步骤
2. **失败即终止**:如果分析返回失败,必须立即停止并汇报
3. **禁止降级处理**:不能使用预设模板或简单摘要来"假装成功"
4. **每次执行都要使用 AI**:必须有真实的 AI API 调用记录
## 示例
```
用户: 添加到知识库 https://zhuanlan.zhihu.com/p/123456789
Claude:
✅ 环境变量检查通过
◆ 处理中...
→ 解析 URL: zhihu 专栏 123456789
→ 获取元数据...
→ 爬取内容和评论...
→ 深度分析...
✅ 完成!
标题: 深度学习在推荐系统中的应用
内容目录: ~/work/intermediate/analysis/zhihu_123456789/
归档文件: ~/work/zq/30.资源/AI分析/2026-03-17/zhihu_123456789_analysis.md
```
## 技术架构
```
kb_workflow/
├── kb_workflow.py — CLI 入口
├── auth/
│ └── zhihu_login.py — 知乎登录和 Cookie 管理
├── crawler/
│ ├── zhihu_crawler.py — 知乎爬虫(问答/专栏/评论)
│ └── models.py — 数据模型
├── pipeline/
│ ├── pipeline.py — Pipeline 编排引擎
│ ├── processors/ — 处理器实现
│ │ ├── parse.py — URL 解析
│ │ ├── download.py — 内容下载
│ │ ├── asr.py — 语音识别
│ │ ├── analyze.py — 深度分析
│ │ └── archive.py — 归档
│ ├── platforms/ — 平台适配器
│ │ └── zhihu.py — 知乎适配器(使用 ZhihuCrawler)
│ └── schemas.py — 数据模型
└── deep_analyzer/ — AI 分析器
```
+8
View File
@@ -0,0 +1,8 @@
"""KB Workflow - Knowledge Base Workflow Orchestrator
知识库工作流管道入口。
"""
from .kb_workflow import process_command, main
__all__ = ["process_command", "main"]
+8
View File
@@ -0,0 +1,8 @@
"""Auth module for KB Workflow
Provides authentication and cookie management functionality.
"""
from .zhihu_login import ZhihuLogin, ZhihuLoginResult
__all__ = ["ZhihuLogin", "ZhihuLoginResult"]
+120
View File
@@ -0,0 +1,120 @@
"""Cookie 管理模块
处理 Cookie 的导入、转换和持久化。
"""
import json
from pathlib import Path
from typing import List, Dict, Any
def parse_json_cookies(json_path: str) -> str:
"""从 JSON 文件解析 Cookie 为字符串格式
支持 Chrome Cookie Editor 导出的 JSON 格式:
[{"name": "z_c0", "value": "xxx", "domain": ".zhihu.com"}, ...]
Args:
json_path: JSON 文件路径
Returns:
Cookie 字符串,格式: "name1=value1; name2=value2"
Raises:
ValueError: JSON 格式无效或文件不存在
"""
path = Path(json_path)
if not path.exists():
raise ValueError(f"Cookie file not found: {json_path}")
try:
with open(path, 'r', encoding='utf-8') as f:
cookies = json.load(f)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON format: {e}")
if not isinstance(cookies, list):
raise ValueError("Expected JSON array")
# 过滤知乎域名,转换为 name=value 格式
zhihu_cookies = []
for c in cookies:
if not isinstance(c, dict):
continue
domain = c.get('domain', '')
if 'zhihu.com' in domain:
name = c.get('name', '')
value = c.get('value', '')
if name and value:
zhihu_cookies.append(f"{name}={value}")
return '; '.join(zhihu_cookies)
class CookieManager:
"""Cookie 持久化管理器
管理 Cookie 在 ~/.kb_env 文件中的存储和读取。
"""
ENV_FILE_NAME = ".kb_env"
COOKIE_KEY = "ZHIHU_COOKIE"
def __init__(self, env_path: Path = None):
"""初始化管理器
Args:
env_path: 环境文件路径,默认 ~/.kb_env
"""
self.env_path = env_path or (Path.home() / self.ENV_FILE_NAME)
def save_cookie(self, cookie_str: str) -> None:
"""保存 Cookie 到环境文件
格式: ZHIHU_COOKIE="name1=value1; name2=value2"
Args:
cookie_str: Cookie 字符串
"""
# 读取现有内容
lines = []
if self.env_path.exists():
lines = self.env_path.read_text(encoding='utf-8').splitlines()
# 更新或添加 ZHIHU_COOKIE 行
cookie_line = f'{self.COOKIE_KEY}="{cookie_str}"'
found = False
for i, line in enumerate(lines):
if line.startswith(f"{self.COOKIE_KEY}="):
lines[i] = cookie_line
found = True
break
if not found:
lines.append(cookie_line)
# 写回文件,设置权限 600
self.env_path.write_text('\n'.join(lines) + '\n', encoding='utf-8')
self.env_path.chmod(0o600)
# 清除配置缓存
from kb_workflow.pipeline.config import get_settings
get_settings.cache_clear()
def load_cookie(self) -> str:
"""从环境文件加载 Cookie
Returns:
Cookie 字符串,不存在时返回空字符串
"""
if not self.env_path.exists():
return ""
content = self.env_path.read_text(encoding='utf-8')
for line in content.splitlines():
if line.startswith(f"{self.COOKIE_KEY}="):
# 提取引号内的值
value = line[len(f"{self.COOKIE_KEY}="):]
if value.startswith('"') and value.endswith('"'):
value = value[1:-1]
return value
return ""
+276
View File
@@ -0,0 +1,276 @@
"""Zhihu Login via QR Code
Implements QR code login flow for Zhihu:
1. Navigate to login page
2. Capture QR code from canvas element
3. Poll for login cookie (z_c0)
4. Extract cookie string on success
Reference: MediaCrawler implementation pattern
"""
from datetime import datetime
from pathlib import Path
from typing import Optional, List
from pydantic import BaseModel, Field
from tenacity import Retrying, stop_after_delay, wait_fixed, RetryError
from kb_workflow.metadata.zhihu_extractor import PlaywrightBrowser
class ZhihuLoginResult(BaseModel):
"""Result of QR code login attempt.
Attributes:
success: Whether login was successful
cookie_str: Cookie string if login succeeded, empty otherwise
qrcode_path: Path to saved QR code image
error_message: Error message if login failed
"""
success: bool = Field(..., description="Whether login was successful")
cookie_str: str = Field(default="", description="Cookie string")
qrcode_path: Optional[Path] = Field(None, description="Path to saved QR code image")
error_message: Optional[str] = Field(None, description="Error message if failed")
class CookieValidationResult(BaseModel):
"""Cookie validation result.
Attributes:
valid: Whether the cookie is valid
username: User's display name if valid
user_id: User's ID if valid
reason: Reason for invalid cookie
"""
valid: bool = Field(..., description="Whether cookie is valid")
username: Optional[str] = Field(None, description="User display name")
user_id: Optional[str] = Field(None, description="User ID")
reason: Optional[str] = Field(None, description="Reason if invalid")
class ZhihuLogin:
"""Zhihu QR Code Login Handler.
Handles the QR code login flow for Zhihu:
- Navigate to login page
- Capture QR code screenshot
- Poll for login cookie
- Extract cookie string
Attributes:
LOGIN_URL: Zhihu login page URL
CANVAS_SELECTORS: List of selectors to find QR code canvas
"""
LOGIN_URL = "https://www.zhihu.com/signin"
CANVAS_SELECTORS = [
"canvas.SignIn-qrcode",
"canvas[class*='qrcode']",
"canvas"
]
VALIDATION_URL = "https://www.zhihu.com/api/v4/me"
def __init__(
self,
intermediate_path: str,
headless: bool = True,
timeout: int = 60
):
"""Initialize ZhihuLogin.
Args:
intermediate_path: Path to intermediate directory for QR code storage
headless: Whether to run browser in headless mode
timeout: Login timeout in seconds
"""
self.intermediate_path = Path(intermediate_path)
self.headless = headless
self.timeout = timeout
self.browser: Optional[PlaywrightBrowser] = None
def _ensure_intermediate_dir(self) -> None:
"""Ensure intermediate directory exists."""
self.intermediate_path.mkdir(parents=True, exist_ok=True)
def _find_qrcode_canvas(self):
"""Find QR code canvas element.
Tries multiple selectors to find the QR code canvas.
Returns:
Canvas element handle or None
"""
for selector in self.CANVAS_SELECTORS:
try:
canvas = self.browser.wait_for_selector(selector, timeout=5)
if canvas:
return canvas
except Exception:
continue
return None
def _save_qrcode_image(self, screenshot: bytes) -> Path:
"""Save QR code screenshot to intermediate directory.
Args:
screenshot: PNG bytes from canvas screenshot
Returns:
Path to saved QR code image
"""
self._ensure_intermediate_dir()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"zhihu_qrcode_{timestamp}.png"
filepath = self.intermediate_path / filename
filepath.write_bytes(screenshot)
return filepath
def check_login_cookie(self) -> bool:
"""Check if login cookie (z_c0) exists.
Returns:
True if z_c0 cookie exists, False otherwise
"""
cookies = self.browser.context.cookies()
for cookie in cookies:
if cookie.get("name") == "z_c0":
return True
return False
def get_cookies_str(self) -> str:
"""Get cookie string from browser context.
Returns:
Cookie string in format "name1=value1; name2=value2"
"""
cookies = self.browser.context.cookies()
cookie_pairs = [
f"{cookie['name']}={cookie['value']}"
for cookie in cookies
]
return "; ".join(cookie_pairs)
def _wait_for_login(self) -> None:
"""Wait for user to scan QR code and login.
Uses tenacity Retrying to poll for login cookie with configurable timeout.
Raises:
RetryError: If login timeout exceeded
"""
for attempt in Retrying(
stop=stop_after_delay(self.timeout),
wait=wait_fixed(1),
reraise=True
):
with attempt:
if not self.check_login_cookie():
raise Exception("Not logged in yet")
def login_by_qrcode(self) -> ZhihuLoginResult:
"""Execute QR code login flow.
Main entry point for QR code login:
1. Start browser
2. Navigate to login page
3. Find and capture QR code
4. Wait for user to scan
5. Extract cookies on success
Returns:
ZhihuLoginResult with success status and cookies
"""
qrcode_path = None
try:
# Start browser
self.browser = PlaywrightBrowser(headless=self.headless, timeout=30)
self.browser.start()
# Navigate to login page
self.browser.goto(self.LOGIN_URL, wait_until="networkidle")
# Find QR code canvas
canvas = self._find_qrcode_canvas()
if not canvas:
return ZhihuLoginResult(
success=False,
error_message="Could not find QR code canvas on login page"
)
# Capture screenshot
screenshot = canvas.screenshot()
qrcode_path = self._save_qrcode_image(screenshot)
# Wait for login
try:
self._wait_for_login()
except RetryError:
return ZhihuLoginResult(
success=False,
qrcode_path=qrcode_path,
error_message=f"Login timeout after {self.timeout} seconds"
)
# Extract cookies
cookie_str = self.get_cookies_str()
return ZhihuLoginResult(
success=True,
cookie_str=cookie_str,
qrcode_path=qrcode_path
)
except Exception as e:
return ZhihuLoginResult(
success=False,
qrcode_path=qrcode_path,
error_message=str(e)
)
finally:
if self.browser:
self.browser.close()
async def validate_cookie(self, cookie_str: str = None) -> CookieValidationResult:
"""Validate cookie validity.
Checks cookie by requesting Zhihu user info API.
Args:
cookie_str: Cookie string to validate. If None, uses already loaded cookie.
Returns:
CookieValidationResult with validation status and user info
"""
# If cookie_str provided, need to restart browser with new cookie
if cookie_str:
if self.browser.browser:
self.browser.close()
self.browser.start(cookie=cookie_str)
elif not self.browser.page:
return CookieValidationResult(valid=False, reason="No cookie provided")
try:
# Use Playwright's request API
response = await self.browser.page.request.get(self.VALIDATION_URL)
if response.status == 200:
user_info = await response.json()
return CookieValidationResult(
valid=True,
username=user_info.get("name", ""),
user_id=str(user_info.get("id", ""))
)
elif response.status == 401:
return CookieValidationResult(valid=False, reason="Cookie expired")
else:
return CookieValidationResult(
valid=False,
reason=f"Validation failed with status {response.status}"
)
except Exception as e:
return CookieValidationResult(valid=False, reason=str(e))
+39
View File
@@ -0,0 +1,39 @@
"""Zhihu Crawler Module
Provides content crawling capabilities for Zhihu using Playwright API interception.
"""
from .models import (
AuthorInfo,
ContentMetadata,
AnswerData,
ArticleData,
CommentAuthor,
CommentData,
CommentsResult,
CrawlResult,
)
from .config import (
ProxyConfig,
CrawlerConfig,
get_crawler_config,
reset_crawler_config,
)
from .zhihu_crawler import ZhihuCrawler, ZhihuApiInterceptor
__all__ = [
"AuthorInfo",
"ContentMetadata",
"AnswerData",
"ArticleData",
"CommentAuthor",
"CommentData",
"CommentsResult",
"CrawlResult",
"ProxyConfig",
"CrawlerConfig",
"get_crawler_config",
"reset_crawler_config",
"ZhihuCrawler",
"ZhihuApiInterceptor",
]
+141
View File
@@ -0,0 +1,141 @@
"""Crawler configuration
Handles proxy settings, rate limiting, and retry configuration.
"""
import os
from urllib.parse import urlparse
from typing import Optional, Dict, Any
from pydantic import BaseModel, Field
class ProxyConfig(BaseModel):
"""Proxy configuration for crawler requests.
Attributes:
server: Proxy server address (e.g., http://127.0.0.1:7890)
username: Optional proxy username for authentication
password: Optional proxy password for authentication
"""
server: str = Field(..., description="Proxy server address")
username: Optional[str] = Field(None, description="Proxy username")
password: Optional[str] = Field(None, description="Proxy password")
def to_playwright_format(self) -> Dict[str, Any]:
"""Convert to Playwright proxy format.
Returns:
Dictionary compatible with browser.new_context(proxy=...)
"""
config = {"server": self.server}
if self.username:
config["username"] = self.username
if self.password:
config["password"] = self.password
return config
class CrawlerConfig(BaseModel):
"""Crawler configuration.
Handles proxy settings, rate limiting, and retry behavior.
Attributes:
proxy: Optional proxy configuration
proxy_required: Whether proxy is mandatory (raises error if not configured)
min_delay: Minimum delay between requests (seconds)
max_delay: Maximum delay between requests (seconds)
max_retries: Maximum number of retry attempts
retry_min_wait: Minimum wait time between retries (seconds)
retry_max_wait: Maximum wait time between retries (seconds)
"""
# Proxy settings
proxy: Optional[ProxyConfig] = Field(None, description="Proxy configuration")
proxy_required: bool = Field(default=False, description="Whether proxy is mandatory (False for 旁路由 auto-proxy)")
# Rate limiting
min_delay: float = Field(default=1.0, description="Minimum delay between requests (seconds)")
max_delay: float = Field(default=3.0, description="Maximum delay between requests (seconds)")
# Retry settings
max_retries: int = Field(default=3, description="Maximum retry attempts")
retry_min_wait: float = Field(default=2.0, description="Minimum retry wait (seconds)")
retry_max_wait: float = Field(default=10.0, description="Maximum retry wait (seconds)")
@classmethod
def from_env(cls) -> "CrawlerConfig":
"""Load configuration from environment variables.
Reads HTTP_PROXY or HTTPS_PROXY for proxy settings.
Returns:
CrawlerConfig instance with proxy settings from environment
"""
proxy = cls._parse_proxy_env()
return cls(proxy=proxy)
@staticmethod
def _parse_proxy_env() -> Optional[ProxyConfig]:
"""Parse proxy configuration from environment variables.
Checks HTTPS_PROXY first, then HTTP_PROXY.
Returns:
ProxyConfig if proxy URL found, None otherwise
"""
proxy_url = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
if not proxy_url:
return None
parsed = urlparse(proxy_url)
server = f"{parsed.scheme}://{parsed.hostname}"
if parsed.port:
server += f":{parsed.port}"
return ProxyConfig(
server=server,
username=parsed.username,
password=parsed.password
)
def validate_proxy(self) -> None:
"""Validate proxy configuration.
Raises:
ValueError: If proxy_required=True but no proxy is configured
"""
if self.proxy_required and not self.proxy:
raise ValueError(
"Proxy configuration missing! Please set HTTP_PROXY or HTTPS_PROXY environment variable.\n"
"Example: export HTTPS_PROXY=http://127.0.0.1:7890"
)
# Module-level cached config
_crawler_config: Optional[CrawlerConfig] = None
def get_crawler_config() -> CrawlerConfig:
"""Get crawler configuration singleton.
Returns:
CrawlerConfig instance loaded from environment variables
"""
global _crawler_config
if _crawler_config is None:
_crawler_config = CrawlerConfig.from_env()
return _crawler_config
def reset_crawler_config() -> None:
"""Reset the cached crawler configuration.
Useful when environment variables change during runtime.
"""
global _crawler_config
_crawler_config = None
+152
View File
@@ -0,0 +1,152 @@
"""Zhihu Crawler Data Models
Pydantic models for structured data from Zhihu API responses.
"""
from typing import Optional, Union, List
from pydantic import BaseModel, Field
class AuthorInfo(BaseModel):
"""Author information from Zhihu content.
Attributes:
name: Author display name
url_token: Author URL token (e.g., 'zhang-san-12')
headline: Author bio/headline
"""
name: str = Field(..., description="Author display name")
url_token: Optional[str] = Field(None, description="Author URL token")
headline: Optional[str] = Field(None, description="Author bio/headline")
class ContentMetadata(BaseModel):
"""Metadata for Zhihu content (answers, articles, etc).
Attributes:
author: Author information
voteup_count: Number of upvotes/likes
comment_count: Number of comments
created_time: Unix timestamp of creation
updated_time: Unix timestamp of last update
"""
author: Optional[AuthorInfo] = Field(None, description="Author info")
voteup_count: int = Field(default=0, description="Upvote count")
comment_count: int = Field(default=0, description="Comment count")
created_time: Optional[int] = Field(None, description="Creation timestamp")
updated_time: Optional[int] = Field(None, description="Update timestamp")
class AnswerData(BaseModel):
"""Data model for Zhihu answer.
Attributes:
id: Answer ID
question_id: Question ID this answer belongs to
question_title: Title of the question
content: Answer content in HTML format
excerpt: Plain text excerpt/summary
metadata: Additional metadata (author, votes, etc.)
"""
id: int = Field(..., description="Answer ID")
question_id: int = Field(..., description="Question ID")
question_title: str = Field(..., description="Question title")
content: str = Field(default="", description="Answer content (HTML)")
excerpt: str = Field(default="", description="Plain text excerpt")
metadata: ContentMetadata = Field(
default_factory=ContentMetadata,
description="Content metadata"
)
class ArticleData(BaseModel):
"""Data model for Zhihu zhuanlan article.
Attributes:
id: Article ID
title: Article title
content: Article content in HTML format
excerpt: Plain text excerpt/summary
metadata: Additional metadata (author, votes, etc.)
"""
id: int = Field(..., description="Article ID")
title: str = Field(..., description="Article title")
content: str = Field(default="", description="Article content (HTML)")
excerpt: str = Field(default="", description="Plain text excerpt")
metadata: ContentMetadata = Field(
default_factory=ContentMetadata,
description="Content metadata"
)
class CommentAuthor(BaseModel):
"""Comment author information.
Attributes:
name: Author display name
url_token: Author URL token (e.g., 'zhang-san-12')
"""
name: str = Field(..., description="Author display name")
url_token: Optional[str] = Field(None, description="Author URL token")
class CommentData(BaseModel):
"""Data model for Zhihu comment.
Attributes:
id: Comment ID
content: Comment content in plain text
author: Comment author information
created_time: Unix timestamp of creation
like_count: Number of likes/upvotes
child_count: Number of child/reply comments
is_author: Whether the commenter is the content author
"""
id: int = Field(..., description="Comment ID")
content: str = Field(default="", description="Comment content")
author: Optional[CommentAuthor] = Field(None, description="Comment author")
created_time: Optional[int] = Field(None, description="Creation timestamp")
like_count: int = Field(default=0, description="Like/upvote count")
child_count: int = Field(default=0, description="Number of child comments")
is_author: bool = Field(default=False, description="Whether commenter is content author")
class CommentsResult(BaseModel):
"""Result of comment crawling operation.
Attributes:
content_type: Type of content ("answers" or "articles")
content_id: ID of the content
comments: List of comments
total_count: Total number of comments (may exceed returned count)
"""
content_type: str = Field(..., description="Content type: answers or articles")
content_id: int = Field(..., description="Content ID")
comments: List[CommentData] = Field(
default_factory=list,
description="List of comments"
)
total_count: int = Field(default=0, description="Total comment count")
class CrawlResult(BaseModel):
"""Result of a crawl operation.
Attributes:
success: Whether the crawl was successful
content_type: Type of content ("answer" or "article")
data: Extracted content data if successful
comments: Optional list of comments (when include_comments=True)
error: Error message if failed
"""
success: bool = Field(..., description="Whether crawl succeeded")
content_type: str = Field(default="", description="Content type: answer or article")
data: Optional[Union[AnswerData, ArticleData]] = Field(
None,
description="Extracted content data"
)
comments: Optional[List[CommentData]] = Field(
None,
description="Comments if include_comments was True"
)
error: Optional[str] = Field(None, description="Error message if failed")
+594
View File
@@ -0,0 +1,594 @@
"""Zhihu Content Crawler
Implements content crawling using Playwright API interception.
Captures structured JSON data from Zhihu API responses.
Supports:
- Mandatory proxy configuration
- Random request delay
- Retry with exponential backoff
- Automatic x-zse-96 signature handling via browser
"""
import random
import re
import time
from typing import Optional, Dict, Any
from playwright.sync_api import Route, Response, sync_playwright
from tenacity import Retrying, stop_after_attempt, wait_exponential, RetryError
from kb_workflow.metadata.zhihu_extractor import PlaywrightBrowser
# NOTE: get_settings is imported lazily in _get_cookie() to avoid circular import
# Circular: zhihu_crawler -> pipeline.config -> pipeline.platforms -> zhihu_crawler
from .models import (
AnswerData,
ArticleData,
ContentMetadata,
AuthorInfo,
CommentData,
CommentAuthor,
CommentsResult,
CrawlResult,
)
from .config import CrawlerConfig, get_crawler_config
class ZhihuApiInterceptor:
"""API Interceptor for capturing Zhihu API responses.
Intercepts network requests and captures JSON responses from
Zhihu API endpoints (/api/v4/answers/, /api/v4/articles/, etc).
The x-zse-96 signature is automatically handled by the browser via route.fetch().
Attributes:
captured_data: Dictionary storing captured API response data
"""
def __init__(self):
self.captured_data: Dict[str, Any] = {}
def handle_route(self, route: Route) -> None:
"""Handle intercepted route/request.
The route.fetch() call automatically includes browser-generated
x-zse-96 signature headers, No manual signature handling needed.
Args:
route: Playwright Route object for the intercepted request
"""
url = route.request.url
# Intercept answer API
if "/api/v4/answers/" in url:
response = route.fetch()
self.captured_data["answer"] = response.json()
route.fulfill(response=response)
return
# Intercept article API (zhuanlan)
if "/api/v4/articles/" in url or "zhuanlan" in url:
response = route.fetch()
self.captured_data["article"] = response.json()
route.fulfill(response=response)
return
route.continue_()
class ZhihuCrawler:
"""Zhihu Content Crawler.
Crawls Zhihu answers and articles using Playwright API interception.
Includes anti-scraping measures:
- Mandatory proxy configuration (configurable via proxy_required)
- Random delay between requests (1-3 seconds by default)
- Retry with exponential backoff (3 attempts by default)
- Automatic x-zse-96 signature handling via browser
Attributes:
ANSWER_PATTERN: Regex pattern for answer URLs
ARTICLE_PATTERN: Regex pattern for zhuanlan article URLs
COMMENT_API_TEMPLATE: Template for comment API URL
"""
ANSWER_PATTERN = r"zhihu\.com/question/(\d+)/answer/(\d+)"
ARTICLE_PATTERN = r"zhuanlan\.zhihu\.com/p/(\d+)"
COMMENT_API_TEMPLATE = (
"https://www.zhihu.com/api/v4/comment_v5/"
"{content_type}/{content_id}/comments"
"?order=hot&limit=20"
)
def __init__(
self,
headless: bool = True,
timeout: int = 30,
config: Optional[CrawlerConfig] = None
):
"""Initialize ZhihuCrawler.
Args:
headless: Whether to run browser in headless mode
timeout: Request timeout in seconds
config: Optional CrawlerConfig instance (uses environment if not provided)
"""
self.headless = headless
self.timeout = timeout
self.config = config or get_crawler_config()
self.browser: Optional[PlaywrightBrowser] = None
self.interceptor: Optional[ZhihuApiInterceptor] = None
# Internal browser management for proxy support
self._playwright = None
self._browser = None
self._context = None
self._page = None
def _get_cookie(self) -> str:
"""Get Cookie from configuration.
Returns:
Cookie string from settings
"""
# Lazy import to avoid circular dependency:
# zhihu_crawler -> pipeline.config -> pipeline.platforms -> zhihu_crawler
from kb_workflow.pipeline.config import get_settings
settings = get_settings()
return settings.zhihu_cookie.get_secret_value()
def _apply_random_delay(self) -> None:
"""Apply random delay between requests.
Uses min_delay and max_delay from configuration.
"""
delay = random.uniform(self.config.min_delay, self.config.max_delay)
time.sleep(delay)
def _start_browser_with_proxy(self, cookie: str) -> None:
"""Start browser with proxy configuration.
Validates proxy if required and applies to browser context.
Args:
cookie: Cookie string for authentication
Raises:
ValueError: If proxy_required=True but no proxy is configured
"""
# Validate proxy configuration
self.config.validate_proxy()
self._playwright = sync_playwright().start()
self._browser = self._playwright.chromium.launch(headless=self.headless)
# Build context options
context_options = {
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
# Apply proxy if configured
if self.config.proxy:
context_options["proxy"] = self.config.proxy.to_playwright_format()
# Block Service Worker to prevent interference with API interception
context_options["service_workers"] = "block"
self._context = self._browser.new_context(**context_options)
# Add cookies if provided
if cookie:
self._add_cookies(cookie)
self._page = self._context.new_page()
self._page.set_default_timeout(self.timeout * 1000)
def _add_cookies(self, cookie_str: str) -> None:
"""Add cookies to browser context.
Args:
cookie_str: Cookie string in "name=value; name2=value2" format
"""
cookies = []
for item in cookie_str.split(';'):
item = item.strip()
if '=' in item:
name, value = item.split('=', 1)
cookies.append({
'name': name.strip(),
'value': value.strip(),
'domain': '.zhihu.com',
'path': '/',
'secure': True,
'httpOnly': False,
'sameSite': 'Lax'
})
self._context.add_cookies(cookies)
def _close_browser(self) -> None:
"""Close browser and cleanup resources."""
if self._page:
self._page.close()
if self._context:
self._context.close()
if self._browser:
self._browser.close()
if self._playwright:
self._playwright.stop()
def _crawl_with_retry(self, crawl_func, *args, **kwargs) -> CrawlResult:
"""Execute crawl with retry logic.
Applies random delay before each attempt and retries on failure
with exponential backoff.
Args:
crawl_func: Crawl function to execute
*args: Positional arguments for crawl_func
**kwargs: Keyword arguments for crawl_func
Returns:
CrawlResult from crawl_func or error result after retries exhausted
"""
try:
for attempt in Retrying(
stop=stop_after_attempt(self.config.max_retries),
wait=wait_exponential(
multiplier=1,
min=self.config.retry_min_wait,
max=self.config.retry_max_wait
),
reraise=True
):
with attempt:
# Apply random delay before each attempt
self._apply_random_delay()
return crawl_func(*args, **kwargs)
except RetryError as e:
return CrawlResult(
success=False,
error=f"Failed after {self.config.max_retries} retries: {str(e)}"
)
except Exception as e:
return CrawlResult(success=False, error=str(e))
def _parse_author(self, data: Optional[Dict]) -> Optional[AuthorInfo]:
"""Parse author information from API response.
Args:
data: Author data dictionary from API response
Returns:
AuthorInfo object or None if data is empty/None
"""
if data is None:
return None
# Empty dict should return AuthorInfo with empty name
return AuthorInfo(
name=data.get("name", ""),
url_token=data.get("url_token"),
headline=data.get("headline")
)
def _parse_metadata(self, data: Dict) -> ContentMetadata:
"""Parse content metadata from API response.
Args:
data: API response data dictionary
Returns:
ContentMetadata object
"""
return ContentMetadata(
author=self._parse_author(data.get("author")),
voteup_count=data.get("voteup_count", 0),
comment_count=data.get("comment_count", 0),
created_time=data.get("created_time"),
updated_time=data.get("updated_time")
)
def _parse_answer_data(
self,
data: Dict,
answer_id: int,
question_id: int
) -> AnswerData:
"""Parse answer data from API response.
Args:
data: API response data dictionary
answer_id: Answer ID
question_id: Question ID
Returns:
AnswerData object
"""
question = data.get("question", {})
return AnswerData(
id=answer_id,
question_id=question_id,
question_title=question.get("title", ""),
content=data.get("content", ""),
excerpt=data.get("excerpt", ""),
metadata=self._parse_metadata(data)
)
def _parse_article_data(self, data: Dict, article_id: int) -> ArticleData:
"""Parse article data from API response.
Args:
data: API response data dictionary
article_id: Article ID
Returns:
ArticleData object
"""
return ArticleData(
id=article_id,
title=data.get("title", ""),
content=data.get("content", ""),
excerpt=data.get("excerpt", ""),
metadata=self._parse_metadata(data)
)
def crawl_comments(
self,
content_type: str,
content_id: int,
include_comments: bool = True
) -> CommentsResult:
"""Crawl hot comments for a Zhihu content.
Fetches first-level comments using the hot sorting API.
Does not expand nested replies.
Args:
content_type: Content type ("answers" or "articles")
content_id: Content ID
include_comments: Whether to fetch comments (default True)
Returns:
CommentsResult with list of CommentData
"""
if not include_comments:
return CommentsResult(
content_type=content_type,
content_id=content_id,
comments=[],
total_count=0
)
try:
self._start_browser_with_proxy(self._get_cookie())
# Build comment API URL
api_url = self.COMMENT_API_TEMPLATE.format(
content_type=content_type,
content_id=content_id
)
# Use Playwright's request API to directly call the API
response = self._page.request.get(api_url)
if response.status != 200:
return CommentsResult(
content_type=content_type,
content_id=content_id,
comments=[],
total_count=0
)
data = response.json()
comments = []
for item in data.get("data", []):
author_data = item.get("author", {})
comment = CommentData(
id=item.get("id", 0),
content=item.get("content", ""),
author=CommentAuthor(
name=author_data.get("name", ""),
url_token=author_data.get("url_token")
) if author_data else None,
created_time=item.get("created_time"),
like_count=item.get("like_count", 0),
child_count=item.get("child_comment_count", 0),
is_author=item.get("is_author", False)
)
comments.append(comment)
# Get total count from paging
total_count = data.get("paging", {}).get("totals", len(comments))
return CommentsResult(
content_type=content_type,
content_id=content_id,
comments=comments,
total_count=total_count
)
except Exception:
return CommentsResult(
content_type=content_type,
content_id=content_id,
comments=[],
total_count=0
)
finally:
self._close_browser()
def crawl_answer(self, url: str, include_comments: bool = False) -> CrawlResult:
"""Crawl Zhihu answer with retry support.
Args:
url: Zhihu answer URL (e.g., https://www.zhihu.com/question/123/answer/456)
include_comments: Whether to include hot comments (default False)
Returns:
CrawlResult with AnswerData if successful, error message otherwise.
If include_comments=True and successful, comments field will be populated.
"""
return self._crawl_with_retry(
self._crawl_answer_impl, url, include_comments
)
def _crawl_answer_impl(self, url: str, include_comments: bool = False) -> CrawlResult:
"""Implementation of crawl_answer without retry wrapper.
Args:
url: Zhihu answer URL
include_comments: Whether to include hot comments
Returns:
CrawlResult
"""
match = re.search(self.ANSWER_PATTERN, url)
if not match:
return CrawlResult(success=False, error="Invalid answer URL format")
question_id, answer_id = int(match.group(1)), int(match.group(2))
try:
self._start_browser_with_proxy(self._get_cookie())
self.interceptor = ZhihuApiInterceptor()
# Set up route interception - x-zse-96 handled by browser
self._page.route("**/api/v4/**", self.interceptor.handle_route)
# Visit page to trigger API requests
self._page.goto(url, wait_until="networkidle")
# Wait for data capture
time.sleep(2)
if "answer" not in self.interceptor.captured_data:
return CrawlResult(
success=False,
error="Failed to capture answer data from API"
)
answer_data = self.interceptor.captured_data["answer"]
result_data = self._parse_answer_data(
answer_data,
answer_id=answer_id,
question_id=question_id
)
result = CrawlResult(
success=True,
content_type="answer",
data=result_data
)
# Fetch comments if requested
if include_comments:
comments_result = self.crawl_comments(
content_type="answers",
content_id=answer_id,
include_comments=True
)
result.comments = comments_result.comments
return result
except Exception as e:
return CrawlResult(success=False, error=str(e))
finally:
self._close_browser()
def crawl_article(self, url: str) -> CrawlResult:
"""Crawl Zhihu zhuanlan article with retry support.
Args:
url: Zhihu zhuanlan URL (e.g., https://zhuanlan.zhihu.com/p/123)
Returns:
CrawlResult with ArticleData if successful, error message otherwise
"""
return self._crawl_with_retry(self._crawl_article_impl, url)
def _crawl_article_impl(self, url: str) -> CrawlResult:
"""Implementation of crawl_article without retry wrapper.
Args:
url: Zhihu zhuanlan URL
Returns:
CrawlResult
"""
match = re.search(self.ARTICLE_PATTERN, url)
if not match:
return CrawlResult(success=False, error="Invalid article URL format")
article_id = int(match.group(1))
try:
self._start_browser_with_proxy(self._get_cookie())
self.interceptor = ZhihuApiInterceptor()
self._page.route("**/api/v4/**", self.interceptor.handle_route)
self._page.goto(url, wait_until="networkidle")
time.sleep(2)
if "article" not in self.interceptor.captured_data:
# Fallback: DOM parsing
return self._crawl_article_from_dom(url, article_id)
article_data = self.interceptor.captured_data["article"]
result_data = self._parse_article_data(article_data, article_id)
return CrawlResult(
success=True,
content_type="article",
data=result_data
)
except Exception as e:
return CrawlResult(success=False, error=str(e))
finally:
self._close_browser()
def _crawl_article_from_dom(self, url: str, article_id: int) -> CrawlResult:
"""Crawl article using DOM parsing as fallback.
Used when API interception fails for zhuanlan articles.
Args:
url: Article URL
article_id: Article ID
Returns:
CrawlResult with ArticleData from DOM extraction
"""
from kb_workflow.metadata.zhihu_extractor import ZhihuPlaywrightExtractor
extractor = ZhihuPlaywrightExtractor(
headless=self.headless,
timeout=self.timeout,
cookie=self._get_cookie()
)
result = extractor.extract(url)
if not result:
return CrawlResult(
success=False,
error="Failed to extract article from DOM"
)
result_data = ArticleData(
id=article_id,
title=result.get("title", ""),
content=result.get("content", ""),
excerpt=result.get("content_markdown", "")[:200],
metadata=ContentMetadata(
author=AuthorInfo(name=result.get("author", "")),
voteup_count=result.get("vote_up", 0)
)
)
return CrawlResult(success=True, content_type="article", data=result_data)
@@ -0,0 +1,650 @@
#!/usr/bin/env python3
"""Deep Analyzer - AI 深度内容分析工具
一次性调用 AI 生成完整深度分析报告。
Usage:
python deep_analyzer.py --metadata meta.json --content content.json --output analysis.md [--type video]
python deep_analyzer.py --help
"""
import argparse
import json
import os
import sys
import re
from datetime import datetime
from pathlib import Path
from typing import Dict, Any, List
from dataclasses import dataclass, field
# ============== 数据模型 ==============
@dataclass
class AnalysisResult:
"""分析结果"""
summary: str = ""
main_topics: List[str] = field(default_factory=list)
key_insights: List[str] = field(default_factory=list)
recommended_tags: List[str] = field(default_factory=list)
full_analysis: str = ""
original_text: str = "" # 文章类型的原文内容
# ============== AI 客户端 ==============
class SimpleAIClient:
"""AI 客户端"""
def __init__(self, api_key: str = None, provider: str = None, model: str = None, base_url: str = None):
self.api_key = api_key or os.environ.get("AI_API_KEY", "")
self.provider = provider or os.environ.get("AI_PROVIDER", "")
self.model = model or os.environ.get("AI_MODEL", "glm-5")
self.base_url = base_url or os.environ.get("AI_BASE_URL", "")
self.max_retries = 3
def generate(self, prompt: str, max_tokens: int = 16000) -> str:
"""生成文本"""
api_key = os.environ.get("AI_API_KEY", self.api_key)
provider = os.environ.get("AI_PROVIDER", self.provider)
model = os.environ.get("AI_MODEL", self.model)
base_url = os.environ.get("AI_BASE_URL", self.base_url)
if not api_key:
raise ValueError("AI_API_KEY 未设置")
for attempt in range(self.max_retries):
try:
import time
start_time = time.time()
print(f"[AI Client] 开始调用 AI...")
if provider == "openai":
result = self._call_openai(prompt, max_tokens, api_key, model, base_url)
elif provider == "zhipu":
result = self._call_zhipu(prompt, max_tokens, api_key, model, base_url)
elif provider == "minimax":
result = self._call_minimax(prompt, max_tokens, api_key, model, base_url)
else:
raise ValueError(f"不支持的 AI 提供商: {provider}")
elapsed = time.time() - start_time
print(f"[AI Client] AI 调用完成,耗时: {elapsed:.2f}秒")
return result
except Exception as e:
if attempt < self.max_retries - 1:
print(f"[AI Client] 第 {attempt + 1} 次调用失败,{self.max_retries - attempt - 1} 次重试剩余: {e}")
continue
else:
raise
def _call_openai(self, prompt: str, max_tokens: int, api_key: str, model: str, base_url: str) -> str:
import requests
url = f"{base_url}/chat/completions" if base_url else "https://api.openai.com/v1/chat/completions"
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
payload = {"model": model, "max_tokens": max_tokens, "messages": [{"role": "user", "content": prompt}]}
response = requests.post(url, headers=headers, json=payload, timeout=600)
response.raise_for_status()
return response.json()['choices'][0]['message']['content']
def _call_zhipu(self, prompt: str, max_tokens: int, api_key: str, model: str, base_url: str) -> str:
import requests
base_url = base_url or "https://open.bigmodel.cn/api/coding/paas/v4"
url = f"{base_url}/chat/completions"
headers = {"Authorization": f"Bearer {api_key}"}
payload = {"model": model, "messages": [{"role": "user", "content": prompt}], "max_tokens": max_tokens}
response = requests.post(url, headers=headers, json=payload, timeout=600)
response.raise_for_status()
data = response.json()
message = data.get('choices', [{}])[0].get('message', {})
content = message.get('content', '')
reasoning = message.get('reasoning_content', '')
if not content and reasoning:
sentences = reasoning.strip().split('。')
useful = [s.strip() for s in sentences if s.strip() and len(s.strip()) > 10]
if useful:
return '。'.join(useful[-3:]) + '。'
return reasoning.strip()[:500]
return content
def _call_minimax(self, prompt: str, max_tokens: int, api_key: str, model: str, base_url: str) -> str:
import requests
url = base_url or "https://api.minimaxi.com/anthropic/v1/messages"
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
payload = {"model": model, "max_tokens": max_tokens, "messages": [{"role": "user", "content": prompt}]}
response = requests.post(url, headers=headers, json=payload, timeout=600)
response.raise_for_status()
data = response.json()
for item in data.get('content', []):
if item.get('type') == 'text':
return item.get('text', '')
return str(data)
# ============== 分析器 ==============
class DeepAnalyzer:
"""深度内容分析器"""
def __init__(self, ai_client: SimpleAIClient = None):
self.ai_client = ai_client or SimpleAIClient()
def analyze(self, metadata: Dict[str, Any], content: Dict[str, Any], content_type: str = "video") -> AnalysisResult:
"""一次性调用 AI 生成完整分析报告
Args:
metadata: 元数据字典
content: 内容字典
content_type: 内容类型,"video" 或 "article"
"""
# 标准化元数据
metadata = self._normalize_metadata(metadata)
title = metadata.get('title', '未知')
duration = metadata.get('duration', 0)
author = metadata.get('author', '')
pubtime = metadata.get('pubtime', '')
url = metadata.get('url', '')
bvid = metadata.get('bvid', '')
# 推断 platform:优先使用元数据中的值,否则根据 URL 或 content_type 推断
platform = metadata.get('platform', '')
if not platform:
if 'zhihu.com' in url or content_type in ('answer', 'zhuanlan', 'question'):
platform = 'zhihu'
elif 'bilibili.com' in url or bvid:
platform = 'bilibili'
else:
platform = 'bilibili' # 默认值
# 清理 author 字段:移除零宽度空格和前后空白
author = self._clean_author(author)
# 获取内容 - 支持多种字段名
text = content.get('text', '') or content.get('subtitle', '') or content.get('subtitles', '') or content.get('description', '')
print(f"[DeepAnalyzer] 开始分析: {title}")
print(f"[DeepAnalyzer] 类型: {content_type}, 内容长度: {len(text)} 字符")
# 构建 prompt(根据类型选择不同模板)
prompt = self._build_prompt(
title=title,
duration=duration,
duration_str=self._format_duration(duration),
author=author,
pubtime=pubtime,
url=url,
bvid=bvid,
platform=platform,
text=text,
content_type=content_type
)
# 调用 AI
try:
full_analysis = self.ai_client.generate(prompt, max_tokens=16000)
except Exception as e:
print(f"[DeepAnalyzer] AI 调用失败: {e}")
raise
# 解析结果
result = self._parse_analysis(full_analysis, text, duration, content_type)
# 文章/回答类型:追加原文
if content_type in ("article", "answer", "zhuanlan"):
result.original_text = text
return result
def _build_prompt(self, title: str, duration: int, duration_str: str, author: str,
pubtime: str, url: str, bvid: str, platform: str, text: str,
content_type: str = "video") -> str:
"""构建分析 prompt,根据内容类型选择不同模板"""
if content_type in ("article", "answer", "zhuanlan"):
return self._build_article_prompt(title, author, pubtime, url, platform, text)
else:
return self._build_video_prompt(title, duration, duration_str, author, pubtime, url, bvid, platform, text)
def _build_video_prompt(self, title: str, duration: int, duration_str: str, author: str,
pubtime: str, url: str, bvid: str, platform: str, text: str) -> str:
"""构建视频分析 prompt"""
# 计算分片数(按10分钟分片)
num_chunks = max(1, (duration + 599) // 600)
# 截取适当长度的文本(20-30K 字符)
max_chars = 30000
if len(text) > max_chars:
text_sample = text[:max_chars] + "\n\n[...]"
else:
text_sample = text
prompt = f"""你是一个专业的视频内容分析师。请根据以下信息生成深度分析报告:
【视频信息】
标题:{title}
作者:{author}
时长:{duration_str}({duration}秒)
发布日期:{pubtime}
平台:{platform}
URL:{url}
【内容】(完整字幕)
{text_sample}
【输出要求】必须使用以下格式:
## 标题
[自己总结的简洁标题,禁止使用原标题]
## 摘要
[200-400字]
## 主要主题
[从内容中提取不超过10个主题,每个不超过10字]
- 主题1
- 主题2
...
## 关键见解
[若干条深刻见解]
- 见解1
- 见解2
...
## 标签
[从内容中提取3-5个精炼标签,每个不超过10字]
- 标签1
- 标签2
...
## 时间轴
### 第 1 部分 (00:00 - 10:00)
[详细分析,至少500字]
### 第 2 部分 (10:00 - 20:00)
[详细分析,至少500字]
...(以此类推)
【重要】分析应足够详尽,读者阅读后可了解视频核心内容,无需再看原视频。"""
return prompt
def _build_article_prompt(self, title: str, author: str, pubtime: str,
url: str, platform: str, text: str) -> str:
"""构建文章分析 prompt"""
# 文章保留完整内容(不截断,因为需要追加原文)
# 但 prompt 中只取前 20000 字符供 AI 分析
max_chars = 20000
if len(text) > max_chars:
text_sample = text[:max_chars] + "\n\n[...内容过长已截断,但原文会完整保留...]"
else:
text_sample = text
prompt = f"""你是一个专业的文章内容分析师。请根据以下信息生成摘要:
【文章信息】
标题:{title}
作者:{author}
发布日期:{pubtime}
平台:{platform}
URL:{url}
【内容】
{text_sample}
【输出要求】必须使用以下格式:
## 摘要
[300字以内的摘要,描述文章的核心观点和主要内容]
## 主要主题
[从内容中提取不超过5个主题,每个不超过10字]
- 主题1
- 主题2
...
## 标签
[从内容中提取3-5个精炼标签,每个不超过10字]
- 标签1
- 标签2
...
【重要】
1. 摘要应精炼概括文章核心观点,让读者快速了解文章主旨
2. 不要输出时间轴
3. 不要输出关键见解(除非是特别重要的观点)"""
return prompt
def _parse_analysis(self, analysis_text: str, original_text: str, duration: int,
content_type: str = "video") -> AnalysisResult:
"""解析 AI 返回的分析结果"""
result = AnalysisResult()
result.full_analysis = analysis_text
# 提取各部分(使用正则)
lines = analysis_text.split('\n')
# 简单解析:按 ## 标题分割
current_section = ""
in_section = None
for line in lines:
if line.startswith('##'):
# 保存上一个 section
if in_section:
self._store_section(result, in_section, current_section)
# 开始新 section
section_name = line.lstrip('#').strip()
in_section = section_name
current_section = ""
elif in_section:
current_section += line + "\n"
# 存储最后一个 section
if in_section:
self._store_section(result, in_section, current_section)
# 如果解析失败,使用原始文本
if not result.summary:
result.summary = analysis_text[:500]
return result
def _store_section(self, result: AnalysisResult, section_name: str, content: str):
"""存储解析到的 section"""
content = content.strip()
if not content:
return
section_lower = section_name.lower()
if '摘要' in section_lower:
result.summary = content[:500]
elif '主题' in section_lower:
# 提取主题
topics = []
for line in content.split('\n'):
line = line.strip().lstrip('-').strip()
if line and len(line) <= 15:
topics.append(line)
result.main_topics = topics[:10]
elif '见解' in section_lower or '观点' in section_lower:
# 提取见解
insights = []
for line in content.split('\n'):
line = line.strip().lstrip('-').strip()
if line and len(line) > 5:
insights.append(line)
result.key_insights = insights
elif '标签' in section_lower:
# 提取标签
tags = []
for line in content.split('\n'):
line = line.strip().lstrip('-').strip()
if line and len(line) <= 10:
tags.append(line)
result.recommended_tags = tags[:5]
def _normalize_metadata(self, metadata: Dict) -> Dict:
"""标准化元数据"""
if not metadata:
return {}
# 如果已经扁平
if 'title' in metadata:
duration = metadata.get('duration', 0)
if isinstance(duration, str) and ':' in duration:
parts = duration.split(':')
try:
metadata['duration'] = int(parts[0]) * 60 + int(parts[1])
except:
pass
return metadata
# 嵌套结构
if 'metadata' in metadata:
meta = metadata['metadata']
normalized = {}
for k, v in meta.items():
normalized[k] = v
# 处理 duration
duration = normalized.get('duration', 0)
if isinstance(duration, str) and ':' in duration:
parts = duration.split(':')
try:
normalized['duration'] = int(parts[0]) * 60 + int(parts[1])
except:
pass
return normalized
return metadata
def _format_duration(self, seconds: int) -> str:
"""格式化时长"""
h = seconds // 3600
m = (seconds % 3600) // 60
s = seconds % 60
if h > 0:
return f"{h}:{m:02d}:{s:02d}"
return f"{m}:{s:02d}"
def _clean_author(self, author: str) -> str:
"""清理 author 字段:移除零宽度空格和前后空白"""
if not author:
return ''
# 移除零宽度空格 (U+200B) 和其他不可见字符
invisible_chars = ['\u200b', '\u200c', '\u200d', '\ufeff', '\u00ad']
cleaned = author
for char in invisible_chars:
cleaned = cleaned.replace(char, '')
# 移除前后空白和换行
cleaned = cleaned.strip()
# 如果包含换行符,只取第一行
if '\n' in cleaned:
cleaned = cleaned.split('\n')[0].strip()
return cleaned
# ============== 工具函数 ==============
def load_json(file_path: str) -> Dict:
"""加载 JSON 文件"""
if not file_path or not os.path.exists(file_path):
return {}
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
def generate_frontmatter(metadata: Dict, result: AnalysisResult, content_type: str = "video") -> str:
"""生成 Markdown frontmatter"""
# 提取标签
tags = result.recommended_tags or []
# 格式化日期
pubtime = metadata.get('pubtime', '')
if pubtime:
date_str = str(pubtime)[:10]
else:
date_str = datetime.now().strftime('%Y-%m-%d')
# 清理 author 字段
author = metadata.get('author', '未知')
author = _clean_string(author)
# 推断 platform
url = metadata.get('url', '')
platform = metadata.get('platform', '')
if not platform:
if 'zhihu.com' in url or content_type in ('answer', 'zhuanlan', 'question'):
platform = 'zhihu'
elif 'bilibili.com' in url or metadata.get('bvid'):
platform = 'bilibili'
else:
platform = 'bilibili'
fm = ["---",
f"title: \"{metadata.get('title', '未命名')}\"",
f"date: {date_str}",
f"author: {author}",
f"platform: {platform}"]
# 视频类型添加 duration 和 bvid
if content_type == "video":
fm.insert(3, f"duration: {metadata.get('duration_str', metadata.get('duration', 0))}")
bvid = metadata.get('bvid', '')
if bvid:
fm.insert(5, f"bvid: {bvid}")
# 添加 URL
url = metadata.get('url', '')
if url:
fm.append(f"url: {url}")
if tags:
fm.append("tags: [" + ", ".join(f'"{t}"' for t in tags) + "]")
fm.append("---")
return '\n'.join(fm)
def _clean_string(text: str) -> str:
"""清理字符串:移除零宽度空格和前后空白"""
if not text:
return ''
# 移除零宽度空格 (U+200B) 和其他不可见字符
invisible_chars = ['\u200b', '\u200c', '\u200d', '\ufeff', '\u00ad']
cleaned = text
for char in invisible_chars:
cleaned = cleaned.replace(char, '')
# 移除前后空白和换行
cleaned = cleaned.strip()
# 如果包含换行符,只取第一行
if '\n' in cleaned:
cleaned = cleaned.split('\n')[0].strip()
return cleaned
# ============== 主程序 ==============
def main():
parser = argparse.ArgumentParser(description="Deep Analyzer - AI 深度内容分析工具")
parser.add_argument("--metadata", "-m", required=True, help="元数据 JSON 文件路径")
parser.add_argument("--content", "-c", required=True, help="内容 JSON 文件路径")
parser.add_argument("--output", "-o", required=True, help="输出 Markdown 文件路径")
parser.add_argument("--type", "-t", default="video", choices=["video", "article"], help="内容类型")
parser.add_argument("--api-key", "-k", default=None, help="AI API Key")
parser.add_argument("--provider", "-p", default=None, choices=["openai", "zhipu", "minimax"], help="AI 提供商")
parser.add_argument("--model", default=None, help="AI 模型")
parser.add_argument("--base-url", "-u", default=None, help="API Base URL")
parser.add_argument("--no-frontmatter", action="store_true", help="不生成 frontmatter")
args = parser.parse_args()
# 加载数据
print(f"[Main] 加载元数据: {args.metadata}")
raw_metadata = load_json(args.metadata)
print(f"[Main] 加载内容: {args.content}")
content = load_json(args.content)
# 标准化元数据
metadata = {}
if 'title' in raw_metadata:
metadata = raw_metadata.copy()
# 处理 duration
duration = metadata.get('duration', 0)
if isinstance(duration, str) and ':' in duration:
parts = duration.split(':')
try:
metadata['duration'] = int(parts[0]) * 60 + int(parts[1])
except:
pass
metadata['duration_str'] = format_duration(metadata.get('duration', 0))
elif 'metadata' in raw_metadata:
meta = raw_metadata['metadata']
metadata = meta.copy()
duration = metadata.get('duration', 0)
if isinstance(duration, str) and ':' in duration:
parts = duration.split(':')
try:
metadata['duration'] = int(parts[0]) * 60 + int(parts[1])
except:
pass
metadata['duration_str'] = format_duration(metadata.get('duration', 0))
# 初始化
ai_client = SimpleAIClient(
api_key=args.api_key or os.environ.get("AI_API_KEY", ""),
provider=args.provider or os.environ.get("AI_PROVIDER", ""),
model=args.model or os.environ.get("AI_MODEL", "glm-5"),
base_url=args.base_url or os.environ.get("AI_BASE_URL", "")
)
analyzer = DeepAnalyzer(ai_client)
# 执行分析
print(f"[Main] 开始分析...")
result = analyzer.analyze(metadata, content, args.type)
# 使用 AI 生成的原始内容(保留 Markdown 格式)
full_analysis = result.full_analysis
# 删除内容中的"标签" section(只保留 frontmatter 中的 tags)
# 匹配 ## 标签 或 标签(无##)到下一个 ## 或文件结尾之间的内容
full_analysis = re.sub(r'(## ?)?标签\n[\s\S]*?(?=\n## ?|\n时间轴|---|\Z)', '', full_analysis)
# 清理多余的空行
full_analysis = re.sub(r'\n{3,}', '\n\n', full_analysis)
# 文章类型:追加原文
if args.type == "article" and result.original_text:
full_analysis = full_analysis.rstrip() + "\n\n## 原文\n\n" + result.original_text
# 写入输出
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
with open(args.output, 'w', encoding='utf-8') as f:
# 添加 frontmatter
if not args.no_frontmatter:
fm = generate_frontmatter(metadata, result, args.type)
f.write(fm + "\n\n")
# 写入分析内容
f.write(full_analysis)
# 输出结果
output = {
"success": True,
"filename": Path(args.output).name,
"filepath": str(Path(args.output).resolve()),
"message": "深度分析完成",
"stats": {
"summary_length": len(result.summary) if result.summary else 0,
"topics_count": len(result.main_topics),
"insights_count": len(result.key_insights),
"tags_count": len(result.recommended_tags)
}
}
print(json.dumps(output, ensure_ascii=False, indent=2))
def format_duration(seconds: int) -> str:
"""格式化时长"""
h = seconds // 3600
m = (seconds % 3600) // 60
s = seconds % 60
if h > 0:
return f"{h}:{m:02d}:{s:02d}"
return f"{m}:{s:02d}"
if __name__ == "__main__":
try:
main()
except Exception as e:
print(json.dumps({"success": False, "error": str(e)}, ensure_ascii=False, indent=2))
sys.exit(1)
+321
View File
@@ -0,0 +1,321 @@
#!/bin/bash
# =============================================================================
# KB Workflow 环境一键安装脚本
# =============================================================================
# 功能: 检查并安装 kb-workflow 技能所需的全部环境依赖
# 支持: Ubuntu/Debian 系统
#
# 使用方法:
# ./install-env.sh # 检查环境并提示安装命令(检查模式)
# ./install-env.sh --auto # 自动安装所有缺失依赖(自动模式)
# =============================================================================
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# 日志函数
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
log_section() {
echo ""
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE}$1${NC}"
echo -e "${BLUE}========================================${NC}"
}
# 获取脚本目录
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
DEFAULT_KB_PATH="${HOME}/work/knowledge-base"
# 解析参数
AUTO_MODE=false
if [ "$1" = "--auto" ]; then
AUTO_MODE=true
log_info "自动安装模式"
fi
# 检测 Linux 发行版
detect_distro() {
if [ -f /etc/os-release ]; then
. /etc/os-release
echo "$ID"
elif [ -f /etc/debian_version ]; then
echo "debian"
elif [ -f /etc/redhat-release ]; then
echo "rhel"
fi
}
DISTRO=$(detect_distro)
# 检查命令是否存在
command_exists() {
command -v "$1" > /dev/null 2>&1
}
# 检查 Python 包
check_python_package() {
python3 -c "import $1" 2>/dev/null
}
# =============================================================================
# 1. 检查系统依赖
# =============================================================================
log_section "第一步: 检查系统依赖"
MISSING_DEPS=()
# 检查 Python3
if command_exists python3; then
PYTHON_VERSION=$(python3 --version | cut -d' ' -f2)
log_success "Python3 已安装: $PYTHON_VERSION"
else
log_warn "Python3 未安装"
MISSING_DEPS+=("python3")
fi
# 检查 pip3
if python3 -m pip --version > /dev/null 2>&1 || command_exists pip3; then
log_success "pip3 已安装"
else
log_warn "pip3 未安装"
MISSING_DEPS+=("python3-pip")
fi
# 检查 ffmpeg
if command_exists ffmpeg; then
log_success "ffmpeg 已安装"
else
log_warn "ffmpeg 未安装"
MISSING_DEPS+=("ffmpeg")
fi
# 检查 tesseract
if command_exists tesseract; then
log_success "Tesseract OCR 已安装"
else
log_warn "Tesseract OCR 未安装"
MISSING_DEPS+=("tesseract-ocr")
fi
# 检查 git
if command_exists git; then
log_success "Git 已安装"
else
log_warn "Git 未安装"
MISSING_DEPS+=("git")
fi
# =============================================================================
# 2. 自动安装系统依赖(自动模式)
# =============================================================================
if [ ${#MISSING_DEPS[@]} -gt 0 ]; then
if [ "$AUTO_MODE" = true ]; then
log_section "自动安装系统依赖"
log_info "检测到系统: $DISTRO"
case "$DISTRO" in
ubuntu|debian)
log_info "更新包列表..."
sudo apt-get update -qq
log_info "安装依赖: ${MISSING_DEPS[*]}"
sudo apt-get install -y -qq "${MISSING_DEPS[@]}" tesseract-ocr-chi-sim tesseract-ocr-eng
;;
fedora|rhel|centos)
log_info "更新包列表..."
sudo dnf check-update -y || true
log_info "安装依赖: ${MISSING_DEPS[*]}"
sudo dnf install -y "${MISSING_DEPS[@]}" tesseract-langpack-chi_sim tesseract-langpack-eng
;;
arch)
log_info "安装依赖: ${MISSING_DEPS[*]}"
sudo pacman -S --noconfirm "${MISSING_DEPS[@]}" tesseract-data-chi_sim tesseract-data-eng
;;
*)
log_error "不支持的系统: $DISTRO"
exit 1
;;
esac
log_success "系统依赖安装完成"
MISSING_DEPS=()
else
log_section "需要安装的系统依赖"
echo ""
echo "检测到以下依赖未安装:"
for dep in "${MISSING_DEPS[@]}"; do
echo " - $dep"
done
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "请运行以下命令安装:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo -e "${YELLOW}sudo apt-get update${NC}"
echo -e "${YELLOW}sudo apt-get install -y ${MISSING_DEPS[*]} tesseract-ocr-chi-sim tesseract-ocr-eng${NC}"
echo ""
fi
fi
# =============================================================================
# 3. 检查 Python 依赖
# =============================================================================
log_section "第二步: 检查 Python 依赖"
PYTHON_PACKAGES=(
"httpx:httpx"
"aiohttp:aiohttp"
"requests:requests"
"Pillow:PIL"
)
MISSING_PY_PKGS=()
for pkg_info in "${PYTHON_PACKAGES[@]}"; do
pkg_name=$(echo "$pkg_info" | cut -d':' -f1)
import_name=$(echo "$pkg_info" | cut -d':' -f2)
if check_python_package "$import_name" 2>/dev/null; then
log_success "$pkg_name 已安装"
else
log_warn "$pkg_name 未安装"
MISSING_PY_PKGS+=("$pkg_name")
fi
done
# 检查特殊包
check_python_package "playwright" 2>/dev/null && log_success "playwright 已安装" || MISSING_PY_PKGS+=("playwright")
check_python_package "easyocr" 2>/dev/null && log_success "easyocr 已安装" || MISSING_PY_PKGS+=("easyocr")
check_python_package "pytesseract" 2>/dev/null && log_success "pytesseract 已安装" || MISSING_PY_PKGS+=("pytesseract")
# =============================================================================
# 4. 自动安装 Python 依赖(自动模式)
# =============================================================================
if [ ${#MISSING_PY_PKGS[@]} -gt 0 ]; then
if [ "$AUTO_MODE" = true ]; then
log_section "自动安装 Python 包"
log_info "安装: ${MISSING_PY_PKGS[*]}"
pip3 install --user "${MISSING_PY_PKGS[@]}" -q
log_success "Python 包安装完成"
# 安装 Playwright 浏览器
if [[ " ${MISSING_PY_PKGS[*]} " =~ " playwright " ]]; then
log_info "安装 Playwright Chromium 浏览器..."
python3 -m playwright install chromium
log_success "Chromium 浏览器安装完成"
fi
MISSING_PY_PKGS=()
else
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "需要安装以下 Python 包:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
for pkg in "${MISSING_PY_PKGS[@]}"; do
echo " - $pkg"
done
echo ""
echo "请运行以下命令安装:"
echo ""
echo -e "${YELLOW}pip3 install --user ${MISSING_PY_PKGS[*]}${NC}"
if [[ " ${MISSING_PY_PKGS[*]} " =~ " playwright " ]]; then
echo ""
echo -e "${YELLOW}安装完 playwright 后,还需要运行:${NC}"
echo -e "${YELLOW} python3 -m playwright install chromium${NC}"
fi
echo ""
fi
fi
# =============================================================================
# 5. 创建知识库目录结构
# =============================================================================
log_section "第三步: 创建知识库目录结构"
KB_BASE_PATH="${KB_BASE_PATH:-$DEFAULT_KB_PATH}"
log_info "知识库路径: $KB_BASE_PATH"
mkdir -p "$KB_BASE_PATH"/{archive/{subscriptions,pending},metadata,content/{downloads,subtitles},analyzed}
log_success "目录结构创建完成"
# =============================================================================
# 6. 环境变量配置提示
# =============================================================================
log_section "第四步: 环境变量配置"
if [ -z "$KB_BASE_PATH_SET" ]; then
log_warn "KB_BASE_PATH 环境变量未设置"
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "建议添加到 ~/.bashrc 或 ~/.zshrc:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo -e "${YELLOW}export KB_BASE_PATH=\"$KB_BASE_PATH\"${NC}"
echo ""
else
log_success "KB_BASE_PATH 已设置: $KB_BASE_PATH"
fi
# 创建环境变量示例文件
ENV_FILE="${SCRIPT_DIR}/.env.example"
cat > "$ENV_FILE" << 'EOF'
# KB Workflow 环境变量配置
export KB_BASE_PATH="~/work/knowledge-base"
# 可选: B站 Cookie(用于下载会员视频)
# export BILIBILI_COOKIE="SESSDATA=xxx; bili_jct=xxx"
# 可选: AI 分析配置
# export AI_API_KEY="your-api-key"
# export AI_PROVIDER="openai" # 或 zhipu, minimax
# export AI_MODEL="gpt-4"
# 可选: Git 配置
# export KB_GIT_USER="Your Name"
# export KB_GIT_EMAIL="your@email.com"
EOF
log_info "环境变量示例已保存到: $ENV_FILE"
# =============================================================================
# 7. 完成总结
# =============================================================================
log_section "安装完成"
echo ""
if [ ${#MISSING_DEPS[@]} -eq 0 ] && [ ${#MISSING_PY_PKGS[@]} -eq 0 ]; then
echo -e "${GREEN}✅ 所有依赖已安装完成!${NC}"
else
echo -e "${YELLOW}⚠️ 部分依赖需要手动安装${NC}"
echo ""
if [ ${#MISSING_DEPS[@]} -gt 0 ]; then
echo "系统依赖:"
echo -e "${YELLOW}sudo apt-get install -y ${MISSING_DEPS[*]} tesseract-ocr-chi-sim tesseract-ocr-eng${NC}"
fi
if [ ${#MISSING_PY_PKGS[@]} -gt 0 ]; then
echo ""
echo "Python 包:"
echo -e "${YELLOW}pip3 install --user ${MISSING_PY_PKGS[*]}${NC}"
fi
fi
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "使用方法:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " export KB_BASE_PATH=\"~/work/knowledge-base\"
echo " python3 kb_workflow.py --check --url \"[URL]\""
echo ""
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
KB Workflow - Knowledge Base Workflow Orchestrator
知识库工作流管道入口。
此模块提供 process_command() 函数作为管道入口,
内部使用 kb_workflow.pipeline 包实现完整的内容处理流程。
使用方式:
python kb_workflow.py process "https://www.bilibili.com/video/BVxxx"
python kb_workflow.py process "https://..." -o /path/to/output
python kb_workflow.py login # QR code login
python kb_workflow.py login --import-cookie cookies.json
python kb_workflow.py login --status
"""
import argparse
import asyncio
import json
import sys
from pathlib import Path
from typing import Any, Dict, Optional
def process_command(
url: str,
output_dir: Optional[str] = None,
skip_completed: bool = True
) -> Dict[str, Any]:
"""Execute full pipeline for a URL
Args:
url: URL to process
output_dir: Optional output directory for content. Defaults to
KB_INTERMEDIATE_PATH env var or ~/work/intermediate/analysis/
skip_completed: Skip already completed steps (default True)
Returns:
Dictionary with success status, content_dir, and results
"""
try:
from kb_workflow.pipeline.config import get_settings
from kb_workflow.pipeline.pipeline import Pipeline
from kb_workflow.pipeline.processors import (
ASRProcessor, AnalyzeProcessor, ArchiveProcessor,
DownloadProcessor, ParseProcessor
)
from kb_workflow.pipeline.schemas import PipelineConfig
async def run_pipeline():
# Determine output directory - default to intermediate_path
settings = get_settings()
out_dir = Path(output_dir) if output_dir else Path(settings.intermediate_path)
# Create processors
processors = [
ParseProcessor(),
DownloadProcessor(),
ASRProcessor(),
AnalyzeProcessor(),
ArchiveProcessor(),
]
# Create pipeline config
config = PipelineConfig(
steps=["parse", "download", "asr", "analyze", "archive"],
output_dir=out_dir,
skip_completed=skip_completed,
)
# Create and run pipeline
pipeline = Pipeline(
processors=processors,
config=config,
output_dir=out_dir,
)
result = await pipeline.run(url)
return result
# Run async pipeline
result = asyncio.run(run_pipeline())
# Extract archive_file from results if available
archive_file = None
if result.results and "archive" in result.results:
archive_file = result.results["archive"].get("archive_file")
return {
"success": result.success,
"error": result.error_message,
"content_dir": str(result.content_dir) if result.content_dir else None,
"archive_file": archive_file,
"results": result.results,
}
except Exception as e:
return {
"success": False,
"error": str(e),
"content_dir": None,
"results": {},
}
def login_command(
import_cookie: Optional[str] = None,
status: bool = False
) -> Dict[str, Any]:
"""Execute login command
Args:
import_cookie: Path to JSON cookie file
status: Check current cookie status
Returns:
Dictionary with login result
"""
try:
from kb_workflow.auth import ZhihuLogin
from kb_workflow.auth.cookie_manager import CookieManager, parse_json_cookies
from kb_workflow.pipeline.config import get_settings
settings = get_settings()
manager = CookieManager()
# Check Cookie status
if status:
current_cookie = settings.zhihu_cookie.get_secret_value()
if not current_cookie:
return {"valid": False, "reason": "No cookie found in ~/.kb_env"}
async def check_status():
login = ZhihuLogin(intermediate_path=settings.intermediate_path)
try:
login.browser = login.browser or type('obj', (object,), {
'browser': None,
'page': None,
'start': lambda cookie=None: None,
'close': lambda: None
})()
login.browser.start(cookie=current_cookie)
return await login.validate_cookie(current_cookie)
finally:
if login.browser and hasattr(login.browser, 'close'):
login.browser.close()
result = asyncio.run(check_status())
return result.model_dump()
# Import Cookie
if import_cookie:
cookie_str = parse_json_cookies(import_cookie)
manager.save_cookie(cookie_str)
return {"success": True, "message": f"Cookie imported from {import_cookie}"}
# QR code login (synchronous method)
login = ZhihuLogin(intermediate_path=settings.intermediate_path)
result = login.login_by_qrcode()
if result.success and result.cookie_str:
manager.save_cookie(result.cookie_str)
return result.model_dump()
except Exception as e:
return {"success": False, "error": str(e)}
def main():
"""CLI入口 - 用于命令行调用"""
parser = argparse.ArgumentParser(
description='KB Workflow - Knowledge Base Workflow Orchestrator',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
使用示例:
# 处理URL (通过完整管道)
python kb_workflow.py process "https://www.bilibili.com/video/BVxxx"
# 指定输出目录
python kb_workflow.py process "https://..." -o /path/to/output
# 强制重新执行(忽略已完成状态)
python kb_workflow.py process "https://..." --no-skip
# 知乎登录 (二维码扫码)
python kb_workflow.py login
# 导入 Cookie
python kb_workflow.py login --import-cookie cookies.json
# 检查 Cookie 状态
python kb_workflow.py login --status
'''
)
# Create subparsers for commands
subparsers = parser.add_subparsers(dest='command', help='Available commands')
# Process subcommand
process_parser = subparsers.add_parser('process', help='Process URL through full pipeline')
process_parser.add_argument('url', type=str, help='URL to process')
process_parser.add_argument('--output-dir', '-o', type=str, help='Output directory for content')
process_parser.add_argument('--skip-completed', action='store_true', default=True,
help='Skip completed steps (default: True)')
process_parser.add_argument('--no-skip', action='store_false', dest='skip_completed',
help='Re-run all steps')
# Login subcommand
login_parser = subparsers.add_parser('login', help='Zhihu login authentication')
login_parser.add_argument('--import-cookie', '-i', type=str,
help='Import cookie from JSON file')
login_parser.add_argument('--status', action='store_true',
help='Check current cookie status')
args = parser.parse_args()
# Handle process subcommand
if args.command == 'process':
result = process_command(
url=args.url,
output_dir=args.output_dir,
skip_completed=args.skip_completed
)
print(json.dumps(result, ensure_ascii=False, indent=2))
# Handle login subcommand
elif args.command == 'login':
result = login_command(
import_cookie=args.import_cookie,
status=args.status
)
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
parser.print_help()
if __name__ == '__main__':
main()
+12
View File
@@ -0,0 +1,12 @@
"""KB Workflow Metadata Module
提供 B站和知乎的元数据提取功能
"""
from .bilibili_extractor import BilibiliExtractor
from .zhihu_extractor import ZhihuPlaywrightExtractor
__all__ = [
"BilibiliExtractor",
"ZhihuPlaywrightExtractor",
]
@@ -0,0 +1,666 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
B站元数据提取器
独立版本,不依赖 obsidian-kb 结构
支持视频、专栏、UP主信息提取
"""
import asyncio
import json
import logging
import re
from datetime import datetime
from typing import Dict, Any, Optional
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
# 默认请求头
DEFAULT_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://www.bilibili.com/',
'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
}
try:
from playwright.async_api import async_playwright
PLAYWRIGHT_AVAILABLE = True
except ImportError:
PLAYWRIGHT_AVAILABLE = False
try:
from undetected_playwright import stealth_async
STEALTH_AVAILABLE = True
except ImportError:
STEALTH_AVAILABLE = False
try:
import httpx
HTTPX_AVAILABLE = True
except ImportError:
HTTPX_AVAILABLE = False
class BilibiliExtractor:
"""
B站元数据提取器
支持:
- 视频信息提取(标题、作者、统计数据、字幕等)
- 专栏文章提取
- UP主信息提取
"""
# 正则模式
BV_PATTERN = re.compile(r'BV[a-zA-Z0-9]{10}')
CV_PATTERN = re.compile(r'cv(\d+)')
URL_PATTERN = re.compile(r'https?://www\.bilibili\.com/video/(BV[a-zA-Z0-9]{10})')
ARTICLE_URL_PATTERN = re.compile(r'https?://www\.bilibili\.com/read/(cv\d+)')
UP_URL_PATTERN = re.compile(r'https?://space\.bilibili\.com/(\d+)')
def __init__(self, headless: bool = True, timeout: int = 30):
"""
初始化提取器
Args:
headless: 是否使用无头模式(浏览器)
timeout: 超时时间(秒)
"""
self.headless = headless
self.timeout = timeout * 1000 # 转换为毫秒
def extract_video_metadata(self, bvid: str) -> Dict[str, Any]:
"""
提取视频元数据
Args:
bvid: B站视频BV号(如 BV1xx411c7mD)
Returns:
Dict containing metadata, stats, description, subtitle
"""
# 验证并解析BV号
bvid = self._resolve_bvid(bvid)
if not bvid:
raise ValueError(f"无效的BV号: {bvid}")
# 使用异步方法提取
return asyncio.run(self._extract_video_async(bvid))
def extract_article_metadata(self, cvid: str) -> Dict[str, Any]:
"""
提取专栏元数据
Args:
cvid: B站专栏CV号(如 cv43780186)
Returns:
Dict containing article metadata
"""
# 解析CV号
cvid = self._resolve_cvid(cvid)
if not cvid:
raise ValueError(f"无效的CV号: {cvid}")
return asyncio.run(self._extract_article_async(cvid))
def extract_up_metadata(self, mid: str) -> Dict[str, Any]:
"""
提取UP主信息
Args:
mid: B站用户ID(如 7770724)
Returns:
Dict containing UP主信息
"""
# 解析MID
mid = self._resolve_mid(mid)
if not mid:
raise ValueError(f"无效的MID: {mid}")
return asyncio.run(self._extract_up_async(mid))
def _resolve_bvid(self, source_id: str) -> Optional[str]:
"""解析并返回BV号"""
source = source_id.strip()
# 直接BV号
if self.BV_PATTERN.fullmatch(source):
return source
# 完整URL
match = self.URL_PATTERN.match(source)
if match:
return match.group(1)
return None
def _resolve_cvid(self, source_id: str) -> Optional[str]:
"""解析并返回CV号"""
source = source_id.strip()
# 直接CV号(带cv前缀)
if source.startswith('cv'):
match = self.CV_PATTERN.match(source)
if match:
return source
# 纯数字
if source.isdigit():
return f"cv{source}"
# 完整URL
match = self.ARTICLE_URL_PATTERN.match(source)
if match:
return match.group(1)
return None
def _resolve_mid(self, source_id: str) -> Optional[str]:
"""解析并返回MID"""
source = source_id.strip()
# 纯数字
if source.isdigit():
return source
# 完整URL
match = self.UP_URL_PATTERN.match(source)
if match:
return match.group(1)
return None
async def _extract_video_async(self, bvid: str) -> Dict[str, Any]:
"""异步提取视频信息"""
url = f"https://www.bilibili.com/video/{bvid}"
result = {
'metadata': {},
'stats': {},
'subtitle': '',
'description': ''
}
if not PLAYWRIGHT_AVAILABLE:
# 使用 HTTP API
return await self._extract_video_by_api(bvid)
async with async_playwright() as playwright:
try:
browser = await playwright.chromium.launch(
headless=self.headless,
args=[
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-blink-features=AutomationControlled',
'--disable-dev-shm-usage',
]
)
context = await browser.new_context(
viewport={'width': 1920, 'height': 1080},
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
locale='zh-CN',
)
if STEALTH_AVAILABLE:
await stealth_async(context)
page = await context.new_page()
await page.goto(url, wait_until='domcontentloaded', timeout=self.timeout)
await asyncio.sleep(2)
content = await page.content()
video_data = await self._try_extract_video_methods(page, content, bvid)
if video_data:
result['metadata'] = {
'title': video_data.get('title', ''),
'author': video_data.get('author', ''),
'bvid': video_data.get('bvid', ''),
'aid': video_data.get('aid', ''),
'mid': video_data.get('mid', ''),
'pubtime': video_data.get('pubtime', ''),
'duration': video_data.get('duration', ''),
'url': url,
'tags': video_data.get('tags', ''),
}
result['stats'] = {
'view': video_data.get('view', 0),
'danmaku': video_data.get('danmaku', 0),
'reply': video_data.get('reply', 0),
'favorite': video_data.get('favorite', 0),
'coin': video_data.get('coin', 0),
'share': video_data.get('share', 0),
'like': video_data.get('like', 0),
}
result['description'] = video_data.get('desc', '')
await browser.close()
except Exception as e:
logger.error(f"视频提取错误: {e}")
# 回退到 API
return await self._extract_video_by_api(bvid)
return result
async def _extract_video_by_api(self, bvid: str) -> Dict[str, Any]:
"""使用B站API提取视频信息"""
result = {
'metadata': {},
'stats': {},
'subtitle': '',
'description': ''
}
try:
async with httpx.AsyncClient(timeout=30.0, headers=DEFAULT_HEADERS) as client:
# 获取视频信息
response = await client.get('https://api.bilibili.com/x/web-interface/view', params={'bvid': bvid})
if response.status_code == 200:
data = response.json()
if data.get('code') == 0:
video_data = data.get('data', {})
result['metadata'] = {
'title': video_data.get('title', ''),
'author': video_data.get('owner', {}).get('name', ''),
'bvid': video_data.get('bvid', ''),
'aid': video_data.get('aid', ''),
'mid': video_data.get('owner', {}).get('mid', ''),
'pubtime': datetime.fromtimestamp(video_data.get('pubdate', 0)).strftime('%Y-%m-%d %H:%M:%S') if video_data.get('pubdate') else '',
'duration': self._format_duration(video_data.get('duration', 0)),
'url': f"https://www.bilibili.com/video/{bvid}",
'tags': ', '.join([t.get('tag_name', '') for t in video_data.get('tag', [])]),
}
stat = video_data.get('stat', {})
result['stats'] = {
'view': stat.get('view', 0),
'danmaku': stat.get('danmaku', 0),
'reply': stat.get('reply', 0),
'favorite': stat.get('favorite', 0),
'coin': stat.get('coin', 0),
'share': stat.get('share', 0),
'like': stat.get('like', 0),
}
result['description'] = video_data.get('desc', '')
except Exception as e:
logger.error(f"API提取错误: {e}")
return result
async def _try_extract_video_methods(self, page, content: str, bvid: str) -> Dict[str, Any]:
"""尝试多种方法提取视频信息"""
video_data = {}
# 方法1: 从 __INITIAL_STATE__
try:
initial_state = await page.evaluate('() => typeof window.__INITIAL_STATE__ !== "undefined" ? window.__INITIAL_STATE__ : null')
if initial_state:
video_data = self._parse_video_state(initial_state)
if video_data and video_data.get('title'):
return video_data
except:
pass
# 方法2: 使用 API(更可靠)
return await self._extract_video_by_api(bvid)
def _parse_video_state(self, data: Dict) -> Dict[str, Any]:
"""解析 __INITIAL_STATE__ 数据"""
video_data = {}
try:
video = data.get('videoData', {})
video_data['title'] = video.get('title', '')
video_data['bvid'] = video.get('bvid', '')
video_data['aid'] = video.get('aid', '')
owner = video.get('owner', {})
video_data['author'] = owner.get('name', '')
video_data['mid'] = owner.get('mid', '')
stat = video.get('stat', {})
video_data['view'] = stat.get('view', 0)
video_data['danmaku'] = stat.get('danmaku', 0)
video_data['reply'] = stat.get('reply', 0)
video_data['favorite'] = stat.get('favorite', 0)
video_data['coin'] = stat.get('coin', 0)
video_data['share'] = stat.get('share', 0)
video_data['like'] = stat.get('like', 0)
pubdate = video.get('pubdate', 0)
video_data['pubtime'] = datetime.fromtimestamp(pubdate).strftime('%Y-%m-%d %H:%M:%S') if pubdate else ''
video_data['duration'] = self._format_duration(video.get('duration', 0))
video_data['desc'] = video.get('desc', '')
video_data['tags'] = ', '.join([t.get('tag_name', '') for t in video.get('tag', [])])
except:
pass
return video_data
async def _extract_article_async(self, cvid: str) -> Dict[str, Any]:
"""异步提取专栏信息"""
result = {}
if not HTTPX_AVAILABLE:
return {'error': 'httpx 未安装'}
try:
async with httpx.AsyncClient(timeout=30.0, headers=DEFAULT_HEADERS) as client:
# 获取专栏信息
response = await client.get('https://api.bilibili.com/x/article/viewinfo', params={'id': cvid[2:]})
if response.status_code == 200:
data = response.json()
if data.get('code') == 0:
article = data.get('data', {})
result = {
'cvid': cvid,
'title': article.get('title', ''),
'author': article.get('author_name', ''),
'mid': article.get('author_mid', ''),
'publish_time': datetime.fromtimestamp(article.get('publish_time', 0)).strftime('%Y-%m-%d %H:%M:%S') if article.get('publish_time') else '',
'view_count': article.get('stats', {}).get('view', 0),
'like_count': article.get('stats', {}).get('like', 0),
'coin_count': article.get('stats', {}).get('coin', 0),
'share_count': article.get('stats', {}).get('share', 0),
'reply_count': article.get('stats', {}).get('reply', 0),
'url': f"https://www.bilibili.com/read/{cvid}",
'summary': article.get('summary', ''),
'word_count': article.get('words', 0),
}
except Exception as e:
logger.error(f"专栏提取错误: {e}")
result = {'error': str(e)}
return result
async def _extract_up_async(self, mid: str) -> Dict[str, Any]:
"""
异步提取UP主信息(支持订阅跟踪)
Args:
mid: UP主ID
Returns:
Dict containing UP主完整信息
"""
result = {
'type': 'up',
'mid': mid,
'subscribed': False,
'basic_info': {},
'subscription_info': {}
}
if not HTTPX_AVAILABLE:
return {'error': 'httpx 未安装'}
try:
async with httpx.AsyncClient(timeout=30.0, headers=DEFAULT_HEADERS) as client:
# 获取UP主基本信息
response = await client.get('https://api.bilibili.com/x/space/acc/info', params={'mid': mid})
if response.status_code == 200:
data = response.json()
if data.get('code') == 0:
up = data.get('data', {})
result['basic_info'] = {
'name': up.get('name', ''),
'sex': up.get('sex', ''),
'face': up.get('face', ''),
'sign': up.get('sign', ''),
'level': up.get('level', 0),
'vip_type': up.get('vip', {}).get('type', 0),
'vip_status': up.get('vip', {}).get('status', 0),
'following_count': up.get('following', 0),
'follower_count': up.get('follower', 0),
'url': f"https://space.bilibili.com/{mid}",
}
# 获取UP主视频统计
stats_response = await client.get('https://api.bilibili.com/x/space/navnum', params={'mid': mid})
if stats_response.status_code == 200:
stats_data = stats_response.json()
if stats_data.get('code') == 0:
result['basic_info']['video_count'] = stats_data.get('data', {}).get('video', 0)
# 获取UP主专栏数量
article_response = await client.get('https://api.bilibili.com/x/space/article', params={'mid': mid, 'pn': 1, 'ps': 1})
if article_response.status_code == 200:
article_data = article_response.json()
if article_data.get('code') == 0:
result['basic_info']['article_count'] = article_data.get('data', {}).get('count', 0)
# 获取UP主最近视频(用于跟踪更新)
video_response = await client.get('https://api.bilibili.com/x/space/arc/search',
params={'mid': mid, 'pn': 1, 'ps': 1, 'order': 'pubtime'})
if video_response.status_code == 200:
video_data = video_response.json()
if video_data.get('code') == 0:
videos = video_data.get('data', {}).get('list', {}).get('vlist', [])
if videos:
latest_video = videos[0]
result['subscription_info'] = {
'last_video_id': latest_video.get('bvid', ''),
'last_video_title': latest_video.get('title', ''),
'last_video_time': datetime.fromtimestamp(latest_video.get('created', 0)).strftime('%Y-%m-%d %H:%M:%S') if latest_video.get('created') else '',
}
# 获取UP主最近专栏(用于跟踪更新)
article_response = await client.get('https://api.bilibili.com/x/space/article',
params={'mid': mid, 'pn': 1, 'ps': 1, 'sort': 'publish_time'})
if article_response.status_code == 200:
article_data = article_response.json()
if article_data.get('code') == 0:
articles = article_data.get('data', {}).get('articles', [])
if articles:
latest_article = articles[0]
if 'last_article' not in result['subscription_info']:
result['subscription_info']['last_article'] = {}
result['subscription_info']['last_article'] = {
'id': latest_article.get('id', 0),
'title': latest_article.get('title', ''),
'time': datetime.fromtimestamp(latest_article.get('publish_time', 0)).strftime('%Y-%m-%d %H:%M:%S') if latest_article.get('publish_time') else '',
}
except Exception as e:
logger.error(f"UP主提取错误: {e}")
result = {'error': str(e)}
return result
def _format_duration(self, seconds: int) -> str:
"""格式化时长(秒 -> HH:MM:SS)"""
if not seconds:
return ''
hours = seconds // 3600
minutes = (seconds % 3600) // 60
secs = seconds % 60
if hours > 0:
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
else:
return f"{minutes:02d}:{secs:02d}"
async def _extract_series_async(self, mid: str, series_id: str) -> Dict[str, Any]:
"""
异步提取系列合集信息(不包含列表内容)
Args:
mid: UP主ID
series_id: 系列ID
Returns:
Dict containing series basic info
"""
result = {
'type': 'series',
'series_id': series_id,
'mid': mid,
'subscribed': False,
'basic_info': {}
}
if not HTTPX_AVAILABLE:
return {'error': 'httpx 未安装'}
try:
async with httpx.AsyncClient(timeout=30.0, headers=DEFAULT_HEADERS) as client:
# 获取系列信息
response = await client.get('https://api.bilibili.com/x/space/series',
params={'mid': mid, 'series_id': series_id, 'pn': 1, 'ps': 1})
if response.status_code == 200:
data = response.json()
if data.get('code') == 0:
series_info = data.get('data', {}).get('list', {})
result['basic_info'] = {
'title': series_info.get('title', ''),
'description': series_info.get('desc', ''),
'cover': series_info.get('cover', ''),
'mid': mid,
'creator_name': '',
'total_count': series_info.get('cnt', 0),
'last_updated': '',
'update_count': 0
}
# 获取创作者信息
up_info = await client.get('https://api.bilibili.com/x/space/acc/info',
params={'mid': mid})
if up_info.status_code == 200:
up_data = up_info.json()
if up_data.get('code') == 0:
result['basic_info']['creator_name'] = up_data.get('data', {}).get('name', '')
except Exception as e:
logger.error(f"系列合集提取错误: {e}")
result = {'error': str(e)}
return result
async def _extract_readlist_async(self, rl_id: str) -> Dict[str, Any]:
"""
异步提取专栏合集信息(不包含列表内容)
Args:
rl_id: 专栏合集ID (rl开头)
Returns:
Dict containing readlist basic info
"""
result = {
'type': 'readlist',
'readlist_id': rl_id,
'subscribed': False,
'basic_info': {}
}
if not HTTPX_AVAILABLE:
return {'error': 'httpx 未安装'}
try:
async with httpx.AsyncClient(timeout=30.0, headers=DEFAULT_HEADERS) as client:
# 获取专栏合集信息
response = await client.get('https://api.bilibili.com/x/article/readlist',
params={'id': rl_id.replace('rl', '')})
if response.status_code == 200:
data = response.json()
if data.get('code') == 0:
list_info = data.get('data', {})
result['basic_info'] = {
'title': list_info.get('name', ''),
'description': list_info.get('description', ''),
'cover': list_info.get('cover', ''),
'mid': list_info.get('mid', ''),
'creator_name': '',
'total_count': list_info.get('articles_count', 0),
'last_updated': '',
'is_public': list_info.get('public', True)
}
# 获取创作者信息
mid = list_info.get('mid', '')
if mid:
up_info = await client.get('https://api.bilibili.com/x/space/acc/info',
params={'mid': mid})
if up_info.status_code == 200:
up_data = up_info.json()
if up_data.get('code') == 0:
result['basic_info']['creator_name'] = up_data.get('data', {}).get('name', '')
except Exception as e:
logger.error(f"专栏合集提取错误: {e}")
result = {'error': str(e)}
return result
async def _extract_favlist_async(self, fid: str) -> Dict[str, Any]:
"""
异步提取收藏夹信息(不包含列表内容)
Args:
fid: 收藏夹ID
Returns:
Dict containing favlist basic info
"""
result = {
'type': 'favlist',
'favlist_id': fid,
'subscribed': False,
'basic_info': {}
}
if not HTTPX_AVAILABLE:
return {'error': 'httpx 未安装'}
try:
async with httpx.AsyncClient(timeout=30.0, headers=DEFAULT_HEADERS) as client:
# 获取收藏夹信息
response = await client.get('https://api.bilibili.com/x/space/favlist',
params={'fid': fid, 'pn': 1, 'ps': 1})
if response.status_code == 200:
data = response.json()
if data.get('code') == 0:
fav_info = data.get('data', {})
result['basic_info'] = {
'title': fav_info.get('title', ''),
'description': fav_info.get('intro', ''),
'cover': fav_info.get('cover', ''),
'mid': fav_info.get('mid', ''),
'creator_name': '',
'total_count': fav_info.get('media_count', 0),
'is_public': fav_info.get('public', True),
'like_count': fav_info.get('like', 0)
}
# 获取创作者信息
mid = fav_info.get('mid', '')
if mid:
up_info = await client.get('https://api.bilibili.com/x/space/acc/info',
params={'mid': mid})
if up_info.status_code == 200:
up_data = up_info.json()
if up_data.get('code') == 0:
result['basic_info']['creator_name'] = up_data.get('data', {}).get('name', '')
except Exception as e:
logger.error(f"收藏夹提取错误: {e}")
result = {'error': str(e)}
return result
# 测试代码
if __name__ == '__main__':
extractor = BilibiliExtractor()
print("=" * 60)
print("B站元数据提取器测试")
print("=" * 60)
# 测试视频
print("\n测试视频提取: BV1MnFNz5EDL")
result = extractor.extract_video_metadata('BV1MnFNz5EDL')
print(json.dumps(result, ensure_ascii=False, indent=2))
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
B站元数据提取 CLI 工具
支持命令行调用,输出 JSON 格式数据
"""
import argparse
import asyncio
import json
import os
import sys
from pathlib import Path
from typing import Dict, Any
from bilibili_extractor import BilibiliExtractor
def format_number(num: int) -> str:
"""格式化数字"""
if num >= 100000000:
return f"{num / 100000000:.1f}亿"
elif num >= 10000:
return f"{num / 10000:.1f}万"
else:
return f"{num:,}"
def extract_video(extractor: BilibiliExtractor, bvid: str, output_file: str = None) -> Dict[str, Any]:
"""提取视频元数据"""
result = extractor.extract_video_metadata(bvid)
# 如果需要输出到文件
if output_file:
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
return result
def extract_article(extractor: BilibiliExtractor, cvid: str, output_file: str = None) -> Dict[str, Any]:
"""提取专栏元数据"""
result = extractor.extract_article_metadata(cvid)
if output_file:
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
return result
def extract_up(extractor: BilibiliExtractor, mid: str, output_file: str = None) -> Dict[str, Any]:
"""提取UP主信息(包含订阅跟踪所需信息)"""
result = asyncio.run(extractor._extract_up_async(mid))
if output_file:
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
return result
def extract_series(extractor: BilibiliExtractor, mid: str, series_id: str, output_file: str = None) -> Dict[str, Any]:
"""提取系列合集元数据"""
result = asyncio.run(extractor._extract_series_async(mid, series_id))
if output_file:
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
return result
def extract_readlist(extractor: BilibiliExtractor, rl_id: str, output_file: str = None) -> Dict[str, Any]:
"""提取专栏合集元数据"""
result = asyncio.run(extractor._extract_readlist_async(rl_id))
if output_file:
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
return result
def extract_favlist(extractor: BilibiliExtractor, fid: str, output_file: str = None) -> Dict[str, Any]:
"""提取收藏夹元数据"""
result = asyncio.run(extractor._extract_favlist_async(fid))
if output_file:
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
return result
def main():
"""主函数"""
parser = argparse.ArgumentParser(
description='B站元数据提取工具',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
# 提取视频元数据
python bilibili_metadata.py --id "BV1xx411c7mD" --type video
# 提取专栏元数据
python bilibili_metadata.py --id "cv43780186" --type article
# 提取UP主信息
python bilibili_metadata.py --id "7770724" --type up
# 提取系列合集信息
python bilibili_metadata.py --id "123456" --type series --mid "7770724"
# 提取专栏合集信息
python bilibili_metadata.py --id "rl123456" --type readlist
# 提取收藏夹信息
python bilibili_metadata.py --id "123456" --type favlist
# 输出到文件
python bilibili_metadata.py --id "BV1xx411c7mD" --type video --output result.json
"""
)
parser.add_argument(
'--id',
required=True,
help='B站资源ID(BV号、CV号或UP主ID)'
)
parser.add_argument(
'--type',
required=True,
choices=['video', 'article', 'up', 'series', 'readlist', 'favlist'],
help='资源类型'
)
parser.add_argument(
'--mid',
help='UP主ID (用于series类型)'
)
parser.add_argument(
'--output',
'-o',
help='输出文件路径(JSON格式)'
)
parser.add_argument(
'--headless',
action='store_true',
default=True,
help='使用无头模式(浏览器,默认开启,使用API)'
)
parser.add_argument(
'--timeout',
type=int,
default=30,
help='超时时间(秒,默认30)'
)
parser.add_argument(
'--pretty',
action='store_true',
default=True,
help='格式化输出(默认开启)'
)
args = parser.parse_args()
# 创建提取器
extractor = BilibiliExtractor(headless=args.headless, timeout=args.timeout)
try:
# 根据类型提取数据
if args.type == 'video':
result = extract_video(extractor, args.id, args.output)
elif args.type == 'article':
result = extract_article(extractor, args.id, args.output)
elif args.type == 'up':
result = extract_up(extractor, args.id, args.output)
elif args.type == 'series':
if not args.mid:
print(json.dumps({
"success": False,
"error": "series类型需要提供 --mid 参数"
}, ensure_ascii=False, indent=2))
sys.exit(1)
result = extract_series(extractor, args.mid, args.id, args.output)
elif args.type == 'readlist':
result = extract_readlist(extractor, args.id, args.output)
elif args.type == 'favlist':
result = extract_favlist(extractor, args.id, args.output)
else:
print(json.dumps({
"success": False,
"error": f"不支持的类型 {args.type}"
}, ensure_ascii=False, indent=2))
sys.exit(1)
# 生成文件名
platform = "bilibili"
filename = f"{platform}_{args.id}.meta.json"
filepath = args.output or filename
# 统一输出格式
output = {
"success": True,
"filename": filename,
"filepath": str(Path(filepath).resolve()) if filepath else None,
"message": f"{args.type}元数据提取成功"
}
print(json.dumps(output, ensure_ascii=False, indent=2))
# 如果指定了输出路径,则写入文件
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
except Exception as e:
print(json.dumps({
"success": False,
"error": str(e),
"detail": type(e).__name__
}, ensure_ascii=False, indent=2))
sys.exit(1)
if __name__ == '__main__':
main()
@@ -0,0 +1,70 @@
import re
from bs4 import BeautifulSoup
from markdownify import MarkdownConverter as _MarkdownConverter
class MarkdownConverter:
"""HTML 转 Markdown 转换器,保留完整格式
处理知乎等平台的图片懒加载机制:
- 知乎图片的 src 属性可能是 SVG 占位符 (data:image/svg+xml;...)
- 真实图片 URL 存储在 data-actualsrc 或 data-original 属性中
"""
def __init__(self):
self.converter = _MarkdownConverter(
heading_style="ATX",
bullets="*,-",
code_blocks=True,
tables=True,
strip=["script", "style", "nav", "footer", "aside"]
)
def convert(self, html: str) -> str:
"""转换 HTML 为 Markdown
Args:
html: 输入的 HTML 字符串
Returns:
转换后的 Markdown 字符串
"""
# 预处理:处理懒加载图片
html = self._fix_lazy_load_images(html)
return self.converter.convert(html)
def _fix_lazy_load_images(self, html: str) -> str:
"""修复懒加载图片的 src 属性
将 data-actualsrc 或 data-original 属性的值替换到 src 属性中,
以便 markdownify 能够正确提取真实图片 URL。
同时移除 <noscript> 标签,因为知乎等平台在 noscript 中包含备用图片,
会导致修复后图片重复出现。
Args:
html: 输入的 HTML 字符串
Returns:
处理后的 HTML 字符串
"""
soup = BeautifulSoup(html, 'html.parser')
# 先移除 noscript 标签,避免图片重复
for noscript in soup.find_all('noscript'):
noscript.decompose()
for img in soup.find_all('img'):
src = img.get('src', '')
# 如果 src 不是占位符,保持原样
if not src or not src.startswith('data:'):
continue
# 尝试获取真实图片 URL
# 优先 data-actualsrc,其次 data-original
real_src = img.get('data-actualsrc') or img.get('data-original')
if real_src:
img['src'] = real_src
return str(soup)
@@ -0,0 +1,236 @@
#!/usr/bin/env python3
"""
知乎文章爬取脚本 - 基于 MediaCrawler
用于爬取知乎专栏文章并导出到知识库格式
"""
import argparse
import json
import os
import re
import sys
import subprocess
import shutil
from pathlib import Path
from datetime import datetime
# 配置
MEDIACRAWLER_DIR = Path.home() / "work" / "MediaCrawler"
DEFAULT_OUTPUT_DIR = Path.home() / "work" / "knowledge-base" / "data" / "zhihu"
def load_env():
"""加载环境变量"""
env_file = Path.home() / ".kb_env"
if env_file.exists():
with open(env_file) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, val = line.split("=", 1)
os.environ[key.strip()] = val.strip()
def get_zhihu_cookie():
"""获取知乎 Cookie"""
return os.environ.get("ZHIHU_COOKIE", "")
def crawl_zhihu_article(article_url: str, output_dir: Path) -> dict:
"""
使用 MediaCrawler 爬取知乎文章
Args:
article_url: 知乎文章链接
output_dir: 输出目录
Returns:
dict: 爬取结果
"""
# 清理旧数据
data_dir = MEDIACRAWLER_DIR / "data" / "zhihu" / "json"
data_dir.mkdir(parents=True, exist_ok=True)
# 备份现有数据
backup_dir = data_dir / "backup"
backup_dir.mkdir(exist_ok=True)
for f in data_dir.glob("detail_contents_*.json"):
shutil.move(str(f), str(backup_dir / f.name))
# 配置 MediaCrawler
config_file = MEDIACRAWLER_DIR / "config" / "zhihu_config.py"
# 读取配置文件并修改
with open(config_file) as f:
config_content = f.read()
# 强制更新配置中的 ID 列表(每次都更新,确保爬取正确的文章)
article_id = article_url.split("/")[-1]
# 使用正则表达式替换整个 ZHIHU_SPECIFIED_ID_LIST 部分
pattern = r'(ZHIHU_SPECIFIED_ID_LIST\s*=\s*\[)([\s\S]*?)(\])'
replacement = f'\\1\n "{article_url}", # article\n\\3'
new_config = re.sub(pattern, replacement, config_content)
# 如果正则替换失败(没有匹配到),使用简单替换
if new_config == config_content:
# 直接替换整个配置项
if 'ZHIHU_SPECIFIED_ID_LIST' in config_content:
# 找到配置项的范围并替换
lines = config_content.split('\n')
new_lines = []
in_list = False
for line in lines:
if 'ZHIHU_SPECIFIED_ID_LIST' in line and '=' in line:
new_lines.append(f'ZHIHU_SPECIFIED_ID_LIST = ["{article_url}", # article')
in_list = True
elif in_list and line.strip().startswith(']'):
new_lines.append(line)
in_list = False
elif not in_list:
new_lines.append(line)
new_config = '\n'.join(new_lines)
with open(config_file, "w") as f:
f.write(new_config)
# 运行 MediaCrawler
env = os.environ.copy()
cookie = get_zhihu_cookie()
if cookie:
env["ZHIHU_COOKIE"] = cookie
cmd = [
"python3", "main.py",
"--platform", "zhihu",
"--lt", "cookie",
"--type", "detail"
]
print(f"运行命令: {' '.join(cmd)}")
print(f"工作目录: {MEDIACRAWLER_DIR}")
result = subprocess.run(
cmd,
cwd=MEDIACRAWLER_DIR,
env=env,
capture_output=True,
text=True,
timeout=120
)
print(f"返回码: {result.returncode}")
if result.returncode != 0:
print(f"stderr: {result.stderr[:500]}")
# 查找最新的输出文件
output_files = sorted(data_dir.glob("detail_contents_*.json"), reverse=True)
if output_files:
latest_file = output_files[0]
with open(latest_file) as f:
data = json.load(f)
if data:
item = data[0]
return {
"success": True,
"title": item.get("title", ""),
"content": item.get("content_text", ""),
"url": item.get("content_url", ""),
"author": item.get("user_nickname", ""),
"created_time": item.get("created_time", 0),
"voteup": item.get("voteup_count", 0),
"comments": item.get("comment_count", 0),
"content_type": item.get("content_type", "article"),
}
return {"success": False, "error": "未获取到数据"}
def save_to_markdown(data: dict, output_path: Path) -> str:
"""保存为 Markdown 格式"""
created = datetime.fromtimestamp(data["created_time"]).strftime("%Y-%m-%d %H:%M:%S") if data.get("created_time") else ""
content = f"""---
title: "{data['title']}"
author: "{data['author']}"
url: "{data['url']}"
created: "{created}"
voteup: {data.get('voteup', 0)}
comments: {data.get('comments', 0)}
platform: zhihu
type: article
---
# {data['title']}
**作者**: {data['author']}
**来源**: [知乎专栏]({data['url']})
**发布时间**: {created}
**点赞**: {data.get('voteup', 0)}
**评论**: {data.get('comments', 0)}
---
{data['content']}
"""
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)
return str(output_path)
def main():
parser = argparse.ArgumentParser(description="知乎文章爬取工具 (基于 MediaCrawler)")
parser.add_argument("--url", "-u", required=True, help="知乎文章链接")
parser.add_argument("--output", "-o", default=str(DEFAULT_OUTPUT_DIR), help="输出目录")
args = parser.parse_args()
# 加载环境变量
load_env()
# 确保输出目录存在
output_dir = Path(args.output)
output_dir.mkdir(parents=True, exist_ok=True)
print(f"目标文章: {args.url}")
print(f"输出目录: {output_dir}")
# 爬取文章
print("\n开始爬取...")
result = crawl_zhihu_article(args.url, output_dir)
if not result.get("success"):
print(f"爬取失败: {result.get('error', '未知错误')}")
sys.exit(1)
print(f"\n爬取成功!")
print(f"标题: {result['title']}")
print(f"内容长度: {len(result['content'])} 字符")
# 生成文件名
safe_title = "".join(c for c in result['title'] if c.isalnum() or c in " -_")[:50]
filename = f"zhihu_article_{safe_title}.md"
output_path = output_dir / filename
# 保存为 Markdown
save_to_markdown(result, output_path)
print(f"\n已保存到: {output_path}")
# 输出 JSON 格式
print("\n--- JSON 输出 ---")
print(json.dumps({
"success": True,
"type": "article",
"title": result["title"],
"filepath": str(output_path),
"content_length": len(result["content"]),
"message": f"爬取成功: {result['title']}"
}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -0,0 +1,470 @@
"""
知乎 Playwright 内容提取器
使用 Playwright 浏览器自动化工具抓取知乎内容
"""
import re
import os
from typing import Dict, Optional, Any
from urllib.parse import urlparse
from .markdown_converter import MarkdownConverter
class PlaywrightBrowser:
"""Playwright 浏览器管理器"""
def __init__(self, headless: bool = True, timeout: int = 30):
self.headless = headless
self.timeout = timeout * 1000
self.browser = None
self.context = None
self.page = None
def start(self, cookie: str = None):
"""启动浏览器"""
from playwright.sync_api import sync_playwright
self.playwright = sync_playwright().start()
self.browser = self.playwright.chromium.launch(headless=self.headless)
# 创建浏览器上下文
context_options = {
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'
}
self.context = self.browser.new_context(**context_options)
# 如果提供了 Cookie,设置 Cookie
if cookie:
self._add_cookies(cookie)
self.page = self.context.new_page()
self.page.set_default_timeout(self.timeout)
def _add_cookies(self, cookie_str: str):
"""添加 Cookie 到浏览器上下文"""
cookies = []
for item in cookie_str.split(';'):
item = item.strip()
if '=' in item:
name, value = item.split('=', 1)
cookies.append({
'name': name.strip(),
'value': value.strip(),
'domain': '.zhihu.com',
'path': '/',
'secure': True,
'httpOnly': False,
'sameSite': 'Lax'
})
self.context.add_cookies(cookies)
def close(self):
"""关闭浏览器"""
if self.page:
self.page.close()
if self.context:
self.context.close()
if self.browser:
self.browser.close()
if hasattr(self, 'playwright'):
self.playwright.stop()
def goto(self, url: str, wait_until: str = "networkidle"):
"""导航到指定URL"""
return self.page.goto(url, wait_until=wait_until)
def wait_for_selector(self, selector: str, timeout: int = None):
"""等待选择器出现"""
timeout_ms = timeout * 1000 if timeout else self.timeout
return self.page.wait_for_selector(selector, timeout=timeout_ms)
def query_selector(self, selector: str):
"""查询选择器"""
return self.page.query_selector(selector)
def query_selector_all(self, selector: str):
"""查询所有匹配的选择器"""
return self.page.query_selector_all(selector)
def evaluate(self, script: str):
"""执行 JavaScript 代码"""
return self.page.evaluate(script)
class ZhihuPlaywrightExtractor:
"""知乎内容提取器 - 使用 Playwright"""
URL_PATTERNS = {
'zvideo': r'zhihu\.com/zvideo/(\d+)',
'zhuanlan': r'zhuanlan\.zhihu\.com/p/(\d+)',
'answer': r'zhihu\.com/question/\d+/answer/(\d+)',
'question': r'zhihu\.com/question/(\d+)',
}
def __init__(self, headless: bool = True, timeout: int = 30, cookie: str = None):
"""
初始化提取器
Args:
headless: 是否使用无头浏览器
timeout: 超时时间(秒)
cookie: 知乎 Cookie 字符串(可选)
"""
self.cookie = cookie
self.browser = PlaywrightBrowser(headless=headless, timeout=timeout)
self.markdown_converter = MarkdownConverter()
def identify_url_type(self, url: str) -> Optional[str]:
"""
识别 URL 类型
Returns:
'answer', 'article', 'question' 或 None
"""
for url_type, pattern in self.URL_PATTERNS.items():
if re.search(pattern, url):
return url_type
return None
def extract_id(self, url: str) -> Optional[str]:
"""
从 URL 中提取内容 ID
Returns:
内容 ID 或 None
"""
url_type = self.identify_url_type(url)
if url_type:
pattern = self.URL_PATTERNS[url_type]
match = re.search(pattern, url)
if match:
return match.group(1)
return None
def _extract_text_content(self, element) -> str:
"""提取元素的文本内容"""
if element is None:
return ''
return element.inner_text() if hasattr(element, 'inner_text') else ''
def _extract_html_content(self, element) -> str:
"""提取元素的 HTML 内容"""
if element is None:
return ''
return element.inner_html() if hasattr(element, 'inner_html') else ''
def _extract_zvideo_content(self) -> Dict[str, Any]:
"""提取知乎视频内容"""
result = {
'type': 'zvideo',
'title': '',
'author': '',
'content': '',
'content_markdown': '',
'publish_time': '',
'vote_up': 0,
}
# 打印页面标题用于调试
page_title = self.browser.page.title()
print(f"页面标题: {page_title}")
# 提取视频标题
title_elem = self.browser.query_selector('.VideoPlaylistInfo-title')
if title_elem:
result['title'] = self._extract_text_content(title_elem)
# 备用:尝试其他选择器
if not result['title']:
title_elem = self.browser.query_selector('h1')
if title_elem:
result['title'] = self._extract_text_content(title_elem)
# 提取作者信息
author_elem = self.browser.query_selector('.AuthorInfo-name')
if author_elem:
result['author'] = self._extract_text_content(author_elem)
# 提取视频描述
desc_elem = self.browser.query_selector('.Video-desc')
if desc_elem:
html_content = self._extract_html_content(desc_elem)
result['content'] = html_content
result['content_markdown'] = self.markdown_converter.convert(html_content)
# 提取发布时间
time_elem = self.browser.query_selector('.Video-meta time')
if time_elem:
result['publish_time'] = time_elem.get_attribute('datetime') or self._extract_text_content(time_elem)
# 提取点赞数
vote_elem = self.browser.query_selector('.VideoLikeButton-count')
if vote_elem:
vote_text = self._extract_text_content(vote_elem)
try:
result['vote_up'] = int(vote_text.replace(',', ''))
except (ValueError, AttributeError):
pass
return result
def _extract_zhuanlan_content(self) -> Dict[str, Any]:
"""提取知乎专栏文章内容"""
result = {
'type': 'zhuanlan',
'title': '',
'author': '',
'content': '',
'content_markdown': '',
'publish_time': '',
'vote_up': 0,
}
# 打印页面标题用于调试
page_title = self.browser.page.title()
print(f"页面标题: {page_title}")
# 提取文章标题
title_elem = self.browser.query_selector('.Post-Title')
if title_elem:
result['title'] = self._extract_text_content(title_elem)
# 备用
if not result['title']:
title_elem = self.browser.query_selector('h1')
if title_elem:
result['title'] = self._extract_text_content(title_elem)
# 提取作者信息
author_elem = self.browser.query_selector('.AuthorInfo-name')
if author_elem:
result['author'] = self._extract_text_content(author_elem)
# 提取文章内容
content_elem = self.browser.query_selector('.Post-RichText')
if content_elem:
html_content = self._extract_html_content(content_elem)
result['content'] = html_content
result['content_markdown'] = self.markdown_converter.convert(html_content)
# 提取发布时间
time_elem = self.browser.query_selector('.ContentItem-time')
if time_elem:
result['publish_time'] = time_elem.get_attribute('data-tooltip') or self._extract_text_content(time_elem)
# 提取点赞数
vote_elem = self.browser.query_selector('.VoteButton--up .VoteButton-label')
if vote_elem:
vote_text = self._extract_text_content(vote_elem)
try:
result['vote_up'] = int(vote_text.replace(',', ''))
except (ValueError, AttributeError):
pass
return result
def _extract_answer_content(self) -> Dict[str, Any]:
"""提取知乎回答内容"""
result = {
'type': 'answer',
'title': '', # 问题标题
'question_id': '',
'answer_id': '',
'author': '',
'content': '',
'content_markdown': '',
'publish_time': '',
'vote_up': 0,
}
# 打印页面标题用于调试
page_title = self.browser.page.title()
print(f"页面标题: {page_title}")
# 提取问题标题
question_title_elem = self.browser.query_selector('.QuestionHeader-title')
if not question_title_elem:
question_title_elem = self.browser.query_selector('.QuestionHeader-content h1')
if question_title_elem:
result['title'] = self._extract_text_content(question_title_elem)
# 提取回答内容
content_elem = self.browser.query_selector('.RichText')
if not content_elem:
content_elem = self.browser.query_selector('.AnswerItem-richText')
if not content_elem:
content_elem = self.browser.query_selector('[class*="RichText"]')
if content_elem:
html_content = self._extract_html_content(content_elem)
result['content'] = html_content
result['content_markdown'] = self.markdown_converter.convert(html_content)
# 提取作者信息
author_elem = self.browser.query_selector('.AuthorInfo-name')
if author_elem:
result['author'] = self._extract_text_content(author_elem)
# 提取发布时间
time_elem = self.browser.query_selector('.AnswerItem-time')
if not time_elem:
time_elem = self.browser.query_selector('.ContentItem-time')
if time_elem:
result['publish_time'] = time_elem.get_attribute('data-tooltip') or self._extract_text_content(time_elem)
# 提取点赞数
vote_elem = self.browser.query_selector('.VoteButton--up .VoteButton-label')
if not vote_elem:
vote_elem = self.browser.query_selector('.VoteButton .Count')
if vote_elem:
vote_text = self._extract_text_content(vote_elem)
try:
result['vote_up'] = int(vote_text.replace(',', ''))
except (ValueError, AttributeError):
pass
return result
def _extract_question_content(self) -> Dict[str, Any]:
"""提取知乎问题内容(包含所有回答)"""
result = {
'type': 'question',
'title': '',
'question_id': '',
'author': '',
'content': '',
'content_markdown': '',
'publish_time': '',
'answer_count': 0,
}
# 打印页面标题用于调试
page_title = self.browser.page.title()
print(f"页面标题: {page_title}")
# 提取问题标题
title_elem = self.browser.query_selector('.QuestionHeader-title')
if not title_elem:
title_elem = self.browser.query_selector('.QuestionHeader-content h1')
if title_elem:
result['title'] = self._extract_text_content(title_elem)
# 提取问题描述
desc_elem = self.browser.query_selector('.QuestionHeader-detail')
if not desc_elem:
desc_elem = self.browser.query_selector('.QuestionRichText')
if desc_elem:
html_content = self._extract_html_content(desc_elem)
result['content'] = html_content
result['content_markdown'] = self.markdown_converter.convert(html_content)
# 提取回答数量
answer_count_elem = self.browser.query_selector('.List-headerText span')
if answer_count_elem:
count_text = self._extract_text_content(answer_count_elem)
try:
# 提取数字
import re
match = re.search(r'(\d+)', count_text)
if match:
result['answer_count'] = int(match.group(1))
except (ValueError, AttributeError):
pass
return result
def extract(self, url: str) -> Optional[Dict[str, Any]]:
"""
从 URL 提取知乎内容
Args:
url: 知乎 URL
Returns:
包含提取内容的字典,结构如下:
{
'type': 'zvideo' | 'zhuanlan',
'title': str,
'author': str,
'content': str (HTML),
'content_markdown': str (Markdown),
'publish_time': str,
'vote_up': int,
}
如果提取失败返回 None
"""
try:
# 识别 URL 类型
url_type = self.identify_url_type(url)
if not url_type:
print(f"无法识别 URL 类型: {url}")
return None
print(f"URL 类型: {url_type}")
print(f"启动浏览器...")
# 启动浏览器(传入 Cookie)
self.browser.start(cookie=self.cookie)
print(f"访问页面: {url}")
# 访问页面
self.browser.goto(url, wait_until="domcontentloaded")
print("等待页面加载...")
import time
time.sleep(3) # 等待 JavaScript 执行
# 根据类型提取内容
if url_type == 'zvideo':
result = self._extract_zvideo_content()
elif url_type == 'zhuanlan':
result = self._extract_zhuanlan_content()
elif url_type == 'answer':
result = self._extract_answer_content()
elif url_type == 'question':
result = self._extract_question_content()
else:
print(f"不支持的类型: {url_type}")
return None
# 添加额外信息
result['url'] = url
result['content_id'] = self.extract_id(url)
print(f"提取成功! 标题: {result.get('title', '')[:50]}...")
return result
except Exception as e:
print(f"提取知乎内容时出错: {e}")
import traceback
traceback.print_exc()
return None
finally:
# 关闭浏览器
self.browser.close()
def __enter__(self):
"""上下文管理器入口"""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""上下文管理器出口"""
self.browser.close()
return False
# 便捷函数
def extract_zhihu_content(url: str, headless: bool = True, timeout: int = 30) -> Optional[Dict[str, Any]]:
"""
便捷函数:提取知乎内容
Args:
url: 知乎 URL
headless: 是否使用无头浏览器
timeout: 超时时间(秒)
Returns:
提取的内容字典
"""
with ZhihuPlaywrightExtractor(headless=headless, timeout=timeout) as extractor:
return extractor.extract(url)
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""
知乎元数据获取工具
支持以下内容类型:
- zvideo: 知乎视频 (https://www.zhihu.com/zvideo/xxx)
- zhuanlan: 知乎专栏文章 (https://zhuanlan.zhihu.com/p/xxx)
- answer: 知乎回答 (https://www.zhihu.com/question/xxx/answer/yyy)
- question: 知乎问题 (https://www.zhihu.com/question/xxx)
"""
import argparse
import json
import sys
import os
from typing import Dict, Optional, Any
from .zhihu_extractor import ZhihuPlaywrightExtractor
def extract_zvideo(video_id: str, headless: bool = True, cookie: str = None) -> Dict[str, Any]:
"""
获取知乎视频元数据
Args:
video_id: 视频 ID (数字字符串)
headless: 是否使用无头浏览器
cookie: 知乎 Cookie(可选)
Returns:
视频元数据字典
"""
url = f"https://www.zhihu.com/zvideo/{video_id}"
return _extract_metadata(url, "zvideo", headless, cookie)
def extract_zhuanlan(article_id: str, headless: bool = True, cookie: str = None) -> Dict[str, Any]:
"""
获取知乎专栏文章元数据
Args:
article_id: 文章 ID (数字字符串)
headless: 是否使用无头浏览器
cookie: 知乎 Cookie(可选)
Returns:
文章元数据字典
"""
url = f"https://zhuanlan.zhihu.com/p/{article_id}"
return _extract_metadata(url, "zhuanlan", headless, cookie)
def extract_answer(answer_id: str, headless: bool = True, cookie: str = None) -> Dict[str, Any]:
"""
获取知乎回答元数据
Args:
answer_id: 回答 ID (数字字符串)
headless: 是否使用无头浏览器
cookie: 知乎 Cookie(可选)
Returns:
回答元数据字典
"""
# 回答需要构建包含问题ID的URL,但answer_id本身足以识别
# 使用一个占位符问题ID,因为提取器会从URL中提取answer_id
url = f"https://www.zhihu.com/question/0/answer/{answer_id}"
return _extract_metadata(url, "answer", headless, cookie)
def extract_question(question_id: str, headless: bool = True, cookie: str = None) -> Dict[str, Any]:
"""
获取知乎问题元数据
Args:
question_id: 问题 ID (数字字符串)
headless: 是否使用无头浏览器
cookie: 知乎 Cookie(可选)
Returns:
问题元数据字典
"""
url = f"https://www.zhihu.com/question/{question_id}"
return _extract_metadata(url, "question", headless, cookie)
def _extract_metadata(url: str, content_type: str, headless: bool, cookie: str = None) -> Dict[str, Any]:
"""
内部函数:提取元数据
Args:
url: 知乎 URL
content_type: 内容类型 (zvideo/zhuanlan)
headless: 是否使用无头浏览器
cookie: 知乎 Cookie(可选)
Returns:
元数据字典
"""
extractor = ZhihuPlaywrightExtractor(headless=headless, cookie=cookie)
result = extractor.extract(url)
if result:
# 标准化输出格式
metadata = {
"type": content_type,
"id": result.get("content_id", ""),
"url": result.get("url", url),
"title": result.get("title", ""),
"author": result.get("author", ""),
"publish_time": result.get("publish_time", ""),
"vote_up": result.get("vote_up", 0),
"content_markdown": result.get("content_markdown", ""),
"raw_html": result.get("content", ""),
}
return metadata
else:
return {
"type": content_type,
"error": "Failed to extract content",
"url": url
}
def main():
"""命令行入口"""
parser = argparse.ArgumentParser(
description="知乎元数据获取工具",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
支持的类型:
zvideo - 知乎视频 (https://www.zhihu.com/zvideo/123456)
zhuanlan - 知乎专栏文章 (https://zhuanlan.zhihu.com/p/123456)
answer - 知乎回答 (https://www.zhihu.com/question/xxx/answer/yyy)
question - 知乎问题 (https://www.zhihu.com/question/xxx)
示例:
python zhihu_metadata.py --id "123456" --type zvideo
python zhihu_metadata.py --id "123456" --type zhuanlan
python zhihu_metadata.py --id "123456" --type answer
python zhihu_metadata.py --id "123456" --type question
"""
)
parser.add_argument(
"--id", "-i",
required=True,
help="知乎内容 ID (视频ID/文章ID/回答ID/问题ID)"
)
parser.add_argument(
"--type", "-t",
required=True,
choices=["zvideo", "zhuanlan", "answer", "question"],
help="内容类型: zvideo=视频, zhuanlan=专栏文章, answer=回答, question=问题"
)
parser.add_argument(
"--output", "-o",
default=None,
help="输出文件路径 (默认: stdout)"
)
parser.add_argument(
"--headless",
action="store_true",
default=True,
help="使用无头浏览器模式 (默认: True)"
)
args = parser.parse_args()
# 获取知乎 Cookie(优先使用环境变量,其次使用参数)
cookie = os.environ.get('ZHIHU_COOKIE')
# 提取元数据(传入 Cookie)
if args.type == "zvideo":
result = extract_zvideo(args.id, headless=args.headless, cookie=cookie)
elif args.type == "zhuanlan":
result = extract_zhuanlan(args.id, headless=args.headless, cookie=cookie)
elif args.type == "answer":
result = extract_answer(args.id, headless=args.headless, cookie=cookie)
elif args.type == "question":
result = extract_question(args.id, headless=args.headless, cookie=cookie)
else:
print(f"不支持的类型: {args.type}")
sys.exit(1)
# 输出结果
json_output = json.dumps(result, ensure_ascii=False, indent=2)
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
f.write(json_output)
print(f"结果已保存到: {args.output}")
else:
print(json_output)
if __name__ == "__main__":
main()
+67
View File
@@ -0,0 +1,67 @@
"""KB Pipeline - 工作流管道基础设施
提供内容处理工作流的核心 Schema 和工具。
"""
from kb_workflow.pipeline.schemas import (
StepInput,
StepOutput,
StepResult,
StepStatus,
StepRecord,
WorkflowState,
StepDefinition,
)
from kb_workflow.pipeline.state import WorkflowStateManager
from kb_workflow.pipeline.errors import (
KBError,
NetworkError,
ValidationError,
SystemError,
AuthError,
ErrorType,
)
from kb_workflow.pipeline.config import KBSettings, get_settings
from kb_workflow.pipeline.retry import retry_on_network_error
from kb_workflow.pipeline.link_parser import LinkParser, ParsedLink
from kb_workflow.pipeline.idempotency import IdempotencyChecker
from kb_workflow.pipeline.platforms import PlatformAdapter, BilibiliAdapter, ZhihuAdapter
from kb_workflow.pipeline.pipeline import Pipeline
__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",
# Pipeline
"Pipeline",
]
__version__ = "0.1.0"
+72
View File
@@ -0,0 +1,72 @@
"""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()
+174
View File
@@ -0,0 +1,174 @@
"""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
@@ -0,0 +1,80 @@
"""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)
+181
View File
@@ -0,0 +1,181 @@
"""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_workflow.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
+399
View File
@@ -0,0 +1,399 @@
"""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_workflow.pipeline.config import get_settings
from kb_workflow.pipeline.link_parser import LinkParser, ParsedLink
from kb_workflow.pipeline.processors.base import BaseProcessor
from kb_workflow.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_workflow.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_workflow.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_workflow.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}")
@@ -0,0 +1,21 @@
"""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 kb_workflow.pipeline.platforms.base import PlatformAdapter
from kb_workflow.pipeline.platforms.bilibili import BilibiliAdapter
from kb_workflow.pipeline.platforms.zhihu import ZhihuAdapter
__all__ = [
"PlatformAdapter",
"BilibiliAdapter",
"ZhihuAdapter",
]
@@ -0,0 +1,122 @@
"""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
@@ -0,0 +1,485 @@
"""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 kb_workflow.pipeline.platforms.base import PlatformAdapter
from kb_workflow.pipeline.errors import NetworkError, AuthError
from kb_workflow.pipeline.retry import retry_on_network_error
from kb_workflow.pipeline.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 kb_workflow.pipeline.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
@@ -0,0 +1,543 @@
"""Zhihu Platform Adapter
Provides content extraction for Zhihu platform:
- Answer extraction from question/answer URLs
- Zhuanlan (article) extraction from zhuanlan URLs
- Cookie-based authentication with auto-login
- Comment crawling and formatting
- Idempotent execution
Phase 09 - Pipeline Integration:
- Uses ZhihuCrawler instead of ZhihuPlaywrightExtractor
- Comments included by default and appended to content
- Cookie expiration triggers QR code login
"""
from typing import Dict, Any, Optional, List
from datetime import datetime
from pathlib import Path
import re
import asyncio
import html2text
from kb_workflow.pipeline.platforms.base import PlatformAdapter
from kb_workflow.pipeline.errors import AuthError
from kb_workflow.pipeline.config import get_settings
from kb_workflow.pipeline.idempotency import IdempotencyChecker
from kb_workflow.crawler.zhihu_crawler import ZhihuCrawler
from kb_workflow.crawler.models import (
CrawlResult,
AnswerData,
ArticleData,
CommentData,
)
from kb_workflow.auth.zhihu_login import ZhihuLogin, ZhihuLoginResult
class ZhihuAdapter(PlatformAdapter):
"""Zhihu platform adapter for answer and zhuanlan content extraction.
Uses ZhihuCrawler for content extraction with:
- Comment crawling enabled by default
- Cookie expiration auto-detection and QR code login
- Idempotent execution via IdempotencyChecker
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,
output_dir: Optional[Path] = None,
idempotency_checker: Optional[IdempotencyChecker] = None
):
"""Initialize ZhihuAdapter.
Args:
headless: Run browser in headless mode (default: True)
timeout: Page load timeout in seconds (default: 30)
output_dir: Directory for output files (for idempotency checks)
idempotency_checker: Optional IdempotencyChecker instance
"""
self.headless = headless
self.timeout = timeout
self._cookie: Optional[str] = None
self._crawler: Optional[ZhihuCrawler] = None
self._idempotency_checker = idempotency_checker or IdempotencyChecker()
self._output_dir = output_dir
self._html_to_text = html2text.HTML2Text()
self._html_to_text.ignore_links = False
self._html_to_text.body_width = 0
@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_crawler(self) -> ZhihuCrawler:
"""Get or create ZhihuCrawler instance.
Returns:
ZhihuCrawler instance
"""
if self._crawler is None:
self._crawler = ZhihuCrawler(
headless=self.headless,
timeout=self.timeout
)
return self._crawler
def _crawl_with_crawler(
self,
url: str,
content_type: str,
include_comments: bool = True
) -> CrawlResult:
"""Crawl content using ZhihuCrawler.
Args:
url: Full URL to crawl
content_type: Type of content ('answer' or 'zhuanlan')
include_comments: Whether to include comments (default: True)
Returns:
CrawlResult from the crawler
"""
crawler = self._get_crawler()
if content_type == "answer":
return crawler.crawl_answer(url, include_comments=include_comments)
elif content_type == "zhuanlan":
# For articles, we need to crawl comments separately
result = crawler.crawl_article(url)
if result.success and include_comments:
# Fetch comments for articles
from kb_workflow.crawler.models import ArticleData
if isinstance(result.data, ArticleData):
comments_result = crawler.crawl_comments(
content_type="articles",
content_id=result.data.id,
include_comments=True
)
result.comments = comments_result.comments
return result
else:
return CrawlResult(
success=False,
error=f"Unsupported content type: {content_type}"
)
def _is_cookie_expired(self, result: CrawlResult) -> bool:
"""Check if the result indicates cookie expiration.
Args:
result: CrawlResult from crawler
Returns:
True if cookie appears to be expired
"""
if not result.success:
error_msg = result.error or ""
expired_indicators = [
"cookie expired",
"unauthorized",
"forbidden",
"需要登录",
"请先登录",
"登录失效",
]
return any(indicator in error_msg.lower() for indicator in expired_indicators)
return False
def _detect_login_page(self, result: CrawlResult) -> bool:
"""Detect if the result indicates a login page redirect.
This happens when the cookie has expired or is invalid.
Args:
result: CrawlResult from crawler
Returns:
True if login page is detected, False otherwise
"""
if not result.success:
error_msg = result.error or ""
login_indicators = ["登录", "登陆", "sign in", "login"]
return any(indicator in error_msg.lower() for indicator in login_indicators)
return False
def _trigger_qrcode_login(self) -> ZhihuLoginResult:
"""Trigger QR code login flow.
Returns:
ZhihuLoginResult with success status and cookie
"""
# Use intermediate path from settings or default
settings = get_settings()
intermediate_path = getattr(settings, 'intermediate_path', '/tmp')
login = ZhihuLogin(
intermediate_path=str(intermediate_path),
headless=False, # Show browser for QR code display
timeout=120 # 2 minutes to scan
)
return login.login_by_qrcode()
def _convert_crawl_result_to_metadata(
self,
result: CrawlResult,
content_type: str,
source_url: str
) -> Dict[str, Any]:
"""Convert CrawlResult to metadata dictionary.
Args:
result: CrawlResult from crawler
content_type: Type of content
source_url: Original source URL
Returns:
Metadata dictionary
"""
data = result.data
if isinstance(data, AnswerData):
return {
"type": "answer",
"title": data.question_title,
"author": data.metadata.author.name if data.metadata.author else "",
"author_url_token": data.metadata.author.url_token if data.metadata.author else None,
"content": data.content,
"content_markdown": self._html_to_text.handle(data.content),
"excerpt": data.excerpt,
"question_id": data.question_id,
"answer_id": data.id,
"voteup_count": data.metadata.voteup_count,
"comment_count": data.metadata.comment_count,
"created_time": data.metadata.created_time,
"updated_time": data.metadata.updated_time,
"url": source_url,
"has_comments": bool(result.comments),
"comment_count_actual": len(result.comments) if result.comments else 0,
}
elif isinstance(data, ArticleData):
return {
"type": "zhuanlan",
"title": data.title,
"author": data.metadata.author.name if data.metadata.author else "",
"author_url_token": data.metadata.author.url_token if data.metadata.author else None,
"content": data.content,
"content_markdown": self._html_to_text.handle(data.content),
"excerpt": data.excerpt,
"article_id": data.id,
"voteup_count": data.metadata.voteup_count,
"comment_count": data.metadata.comment_count,
"created_time": data.metadata.created_time,
"updated_time": data.metadata.updated_time,
"url": source_url,
"has_comments": bool(result.comments),
"comment_count_actual": len(result.comments) if result.comments else 0,
}
else:
return {
"type": content_type,
"title": "",
"author": "",
"content": "",
"content_markdown": "",
"url": source_url,
"error": "Unknown data type",
}
def _format_comments_section(self, comments: List[CommentData]) -> str:
"""Format comments as Markdown.
Args:
comments: List of CommentData objects
Returns:
Markdown-formatted comment section
"""
if not comments:
return ""
lines = []
for i, comment in enumerate(comments, 1):
author_name = comment.author.name if comment.author else "Anonymous"
like_str = f"({comment.like_count} likes)" if comment.like_count > 0 else ""
lines.append(f"{i}. **@{author_name}** {like_str}: {comment.content}")
return "\n".join(lines)
def _format_with_frontmatter(
self,
metadata: Dict[str, Any],
content: str,
content_type: str,
comments: Optional[List[CommentData]] = None
) -> str:
"""Format content with YAML frontmatter.
Args:
metadata: Content metadata dictionary
content: Markdown content
content_type: 'answer' or 'zhuanlan'
comments: Optional list of comments to append
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()}",
"---",
"",
]
result = "\n".join(frontmatter_lines) + content
# Append comments section if present
if comments:
comments_md = self._format_comments_section(comments)
if comments_md:
result += "\n\n---\n\n## 热门评论\n\n" + comments_md
return result
def _load_cached_metadata(self, metadata_file: Path) -> Optional[Dict[str, Any]]:
"""Load cached metadata from file.
Args:
metadata_file: Path to metadata JSON file
Returns:
Metadata dictionary or None if file doesn't exist or is invalid
"""
import json
try:
if metadata_file.exists():
return json.loads(metadata_file.read_text())
except (json.JSONDecodeError, IOError):
pass
return None
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",
)
# Idempotency check: if output_dir is set and content already processed, return cached result
if self._output_dir:
exists, existing_files = self._idempotency_checker.check_exists(
self._output_dir, content_id, self.PLATFORM
)
if exists:
# Load and return cached metadata
metadata_file = existing_files[0] if existing_files else None
if metadata_file and metadata_file.suffix == ".json":
cached = self._load_cached_metadata(metadata_file)
if cached:
return cached
# Build URL based on content type
if content_type == "answer":
url = source_url or 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}")
# Crawl content in a separate thread to avoid blocking asyncio
result = await asyncio.to_thread(
self._crawl_with_crawler,
url,
content_type,
True # include_comments=True by default
)
# Check for login page / expired cookie
if self._detect_login_page(result) or self._is_cookie_expired(result):
# Try QR code login
login_result = self._trigger_qrcode_login()
if login_result.success:
# Update cookie and retry
self._cookie = login_result.cookie_str
# Retry crawl with new cookie
result = await asyncio.to_thread(
self._crawl_with_crawler,
url,
content_type,
True
)
else:
raise AuthError(
step_name="zhihu_metadata",
input_summary=f"content_id={content_id}, type={content_type}",
auth_type="zhihu_cookie",
cause=Exception(f"Login failed: {login_result.error_message}")
)
if not result.success:
raise AuthError(
step_name="zhihu_metadata",
input_summary=f"content_id={content_id}, type={content_type}",
auth_type="zhihu_cookie",
cause=Exception(result.error or "Failed to extract content")
)
# Convert to metadata dictionary
return self._convert_crawl_result_to_metadata(result, content_type, url)
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 and comments
Raises:
AuthError: If cookie is missing or expired
"""
# Build URL for crawling
if content_type == "answer":
url = source_url or 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}")
# Crawl content with comments
result = await asyncio.to_thread(
self._crawl_with_crawler,
url,
content_type,
True
)
# Handle cookie expiration
if self._detect_login_page(result) or self._is_cookie_expired(result):
login_result = self._trigger_qrcode_login()
if login_result.success:
self._cookie = login_result.cookie_str
result = await asyncio.to_thread(
self._crawl_with_crawler,
url,
content_type,
True
)
else:
raise AuthError(
step_name="zhihu_content",
input_summary=f"content_id={content_id}, type={content_type}",
auth_type="zhihu_cookie",
cause=Exception(f"Login failed: {login_result.error_message}")
)
if not result.success:
raise AuthError(
step_name="zhihu_content",
input_summary=f"content_id={content_id}, type={content_type}",
auth_type="zhihu_cookie",
cause=Exception(result.error or "Failed to extract content")
)
# Convert to metadata
metadata = self._convert_crawl_result_to_metadata(result, content_type, url)
# Get content markdown
content_markdown = metadata.get("content_markdown", "") or metadata.get("content", "")
# Format with YAML frontmatter and comments
return self._format_with_frontmatter(
metadata,
content_markdown,
content_type,
result.comments
)
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}"
@@ -0,0 +1,26 @@
"""Processors module for KB Pipeline
This module contains processors for various content transformation tasks:
- BaseProcessor: Abstract base class for all processors
- ParseProcessor: URL parsing for various platforms
- DownloadProcessor: Metadata and content downloading
- ASRProcessor: Audio-to-text conversion (subtitle download + Whisper.cpp)
- AnalyzeProcessor: AI-powered content analysis using DeepAnalyzer
- ArchiveProcessor: Copy analysis reports to archive directory
"""
from kb_workflow.pipeline.processors.base import BaseProcessor
from kb_workflow.pipeline.processors.parse import ParseProcessor
from kb_workflow.pipeline.processors.download import DownloadProcessor
from kb_workflow.pipeline.processors.asr import ASRProcessor
from kb_workflow.pipeline.processors.analyze import AnalyzeProcessor
from kb_workflow.pipeline.processors.archive import ArchiveProcessor
__all__ = [
"BaseProcessor",
"ParseProcessor",
"DownloadProcessor",
"ASRProcessor",
"AnalyzeProcessor",
"ArchiveProcessor",
]
@@ -0,0 +1,313 @@
"""AnalyzeProcessor - AI-powered content analysis
Unified processor for generating AI analysis reports from content.
Wraps existing DeepAnalyzer for integration into the pipeline.
"""
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import Optional, Dict, Any
from kb_workflow.pipeline.config import get_settings
from kb_workflow.pipeline.schemas import AnalyzeInput, AnalyzeOutput
from kb_workflow.pipeline.state import WorkflowStateManager
from kb_workflow.pipeline.processors.base import BaseProcessor
from kb_workflow.deep_analyzer import DeepAnalyzer, SimpleAIClient, generate_frontmatter
logger = logging.getLogger(__name__)
class AnalyzeProcessor(BaseProcessor):
"""Analyze 处理器 - AI 深度内容分析
处理流程:
1. 检查步骤是否已完成(幂等执行)
2. 加载元数据和内容文件
3. 初始化 AI 客户端
4. 调用 DeepAnalyzer 生成分析报告
5. 生成 frontmatter 并保存 Markdown 文件
6. 记录处理状态到 workflow_state.json
Attributes:
STEP_NAME: 步骤名称,用于状态记录
settings: KB 配置
state_manager: 状态管理器(可选)
"""
STEP_NAME = "analyze"
async def process(self, input_data: AnalyzeInput) -> AnalyzeOutput:
"""执行分析处理
策略:
1. 检查步骤是否已完成(跳过已完成)
2. 加载元数据和内容
3. 调用 DeepAnalyzer
4. 保存分析报告
5. 记录状态
Args:
input_data: Analyze 输入数据
Returns:
Analyze 处理结果
Raises:
Exception: 处理失败时抛出异常
"""
try:
# Step 1: 检查是否已完成(幂等执行)
if self.state_manager:
state = self.state_manager.load()
if state and state.is_step_completed(self.STEP_NAME):
logger.info(f"Step {self.STEP_NAME} already completed, skipping")
# 返回已有结果
existing_file = Path(input_data.output_file)
if existing_file.exists():
return AnalyzeOutput(
success=True,
analysis_file=str(existing_file),
summary="Skipped - already completed",
main_topics=[],
key_insights=[],
tags=[],
stats={"skipped": True}
)
# Step 2: 加载元数据和内容
metadata = self._load_json(input_data.metadata_file)
content = self._load_json(input_data.content_file)
# 处理 Whisper 字幕格式 - 提取完整文本
if content and 'transcription' in content:
# Whisper JSON 格式,提取文本
text_parts = []
for item in content.get('transcription', []):
text_parts.append(item.get('text', ''))
content = {'text': ' '.join(text_parts)}
logger.info(f"Extracted {len(content['text'])} chars from Whisper transcription")
# 对于文章类型,内容可能是纯文本文件
if not content and input_data.content_type in ("article", "answer", "zhuanlan"):
content_path = Path(input_data.content_file)
if content_path.exists():
text = content_path.read_text(encoding="utf-8")
# 去除 YAML frontmatter(如果存在)
text = self._strip_frontmatter(text)
content = {"text": text}
# Step 3: 调用 DeepAnalyzer
result = await self._run_analysis(
metadata=metadata,
content=content,
content_type=input_data.content_type
)
# Step 4: 生成并保存分析报告
output_path = Path(input_data.output_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
self._write_analysis_report(
output_path=output_path,
metadata=metadata,
result=result,
content_type=input_data.content_type
)
# Step 5: 记录成功状态
if self.state_manager:
state = self.state_manager.load()
if state:
summary = f"topics={len(result.main_topics)}, insights={len(result.key_insights)}"
state.mark_step_completed(self.STEP_NAME, summary)
self.state_manager.save(state)
return AnalyzeOutput(
success=True,
analysis_file=str(output_path),
summary=result.summary,
main_topics=result.main_topics,
key_insights=result.key_insights,
tags=result.recommended_tags,
stats={
"topics_count": len(result.main_topics),
"insights_count": len(result.key_insights),
"tags_count": len(result.recommended_tags)
}
)
except Exception as e:
logger.error(f"Analyze failed: {e}")
# 记录失败状态
if self.state_manager:
state = self.state_manager.load()
if state:
state.mark_step_failed(self.STEP_NAME, str(e))
self.state_manager.save(state)
return AnalyzeOutput(
success=False,
error_message=str(e),
analysis_file="",
summary="",
main_topics=[],
key_insights=[],
tags=[],
stats={}
)
def _load_json(self, file_path: str) -> Dict[str, Any]:
"""加载 JSON 文件
Args:
file_path: JSON 文件路径
Returns:
解析后的字典,如果文件不存在或解析失败返回空字典
"""
if not file_path:
return {}
path = Path(file_path)
if not path.exists():
logger.warning(f"File not found: {file_path}")
return {}
try:
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse JSON: {file_path}, error: {e}")
return {}
async def _run_analysis(self, metadata: Dict, content: Dict, content_type: str):
"""调用 DeepAnalyzer 执行分析
Args:
metadata: 元数据字典
content: 内容字典
content_type: 内容类型
Returns:
AnalysisResult 对象
"""
# 初始化 AI 客户端
ai_client = self._create_ai_client()
# 创建分析器实例
analyzer = DeepAnalyzer(ai_client=ai_client)
# 执行分析
return analyzer.analyze(metadata, content, content_type)
def _create_ai_client(self):
"""创建 AI 客户端
Returns:
SimpleAIClient 实例
"""
# 从环境变量获取配置
api_key = os.environ.get("AI_API_KEY", "")
provider = os.environ.get("AI_PROVIDER", "")
model = os.environ.get("AI_MODEL", "glm-5")
base_url = os.environ.get("AI_BASE_URL", "")
return SimpleAIClient(
api_key=api_key,
provider=provider,
model=model,
base_url=base_url
)
def _normalize_metadata(self, metadata: Dict) -> Dict:
"""标准化元数据结构(扁平化嵌套结构)
Args:
metadata: 原始元数据字典,可能是嵌套结构
Returns:
扁平化的元数据字典
"""
if not metadata:
return {}
# 如果已经是扁平结构(顶层有 title)
if 'title' in metadata:
return metadata
# 处理嵌套结构 {metadata: {...}, stats: {...}}
if 'metadata' in metadata:
return metadata['metadata'].copy()
return metadata
def _strip_frontmatter(self, text: str) -> str:
"""去除 Markdown 文件中的 YAML frontmatter
Args:
text: 可能包含 frontmatter 的文本
Returns:
去除 frontmatter 后的文本
"""
if not text:
return text
# 检查是否以 --- 开头
if not text.startswith('---'):
return text
# 找到第二个 ---
lines = text.split('\n')
end_index = -1
for i in range(1, len(lines)):
if lines[i].strip() == '---':
end_index = i
break
if end_index == -1:
return text
# 返回 frontmatter 之后的内容(跳过空行)
remaining = '\n'.join(lines[end_index + 1:])
return remaining.lstrip('\n')
def _write_analysis_report(
self,
output_path: Path,
metadata: Dict,
result,
content_type: str
) -> None:
"""写入分析报告 Markdown 文件
Args:
output_path: 输出文件路径
metadata: 元数据字典
result: AnalysisResult 对象
content_type: 内容类型
"""
# 标准化元数据(扁平化嵌套结构)
normalized_metadata = self._normalize_metadata(metadata)
# 生成 frontmatter
frontmatter = generate_frontmatter(normalized_metadata, result, content_type)
# 获取完整分析内容
full_analysis = result.full_analysis
# 文章/回答类型:追加原文
if content_type in ("article", "answer", "zhuanlan") and result.original_text:
full_analysis = full_analysis.rstrip() + "\n\n## 原文\n\n" + result.original_text
# 写入文件
with open(output_path, 'w', encoding='utf-8') as f:
f.write(frontmatter + "\n\n")
f.write(full_analysis)
logger.info(f"Analysis report saved to: {output_path}")
@@ -0,0 +1,170 @@
"""ArchiveProcessor - Copy analysis reports to archive directory
Final step in pipeline that copies the generated analysis.md
to the archive directory organized by platform.
"""
from __future__ import annotations
import logging
import shutil
from datetime import datetime
from pathlib import Path
from typing import Optional
from kb_workflow.pipeline.config import get_settings
from kb_workflow.pipeline.schemas import ArchiveInput, ArchiveOutput
from kb_workflow.pipeline.state import WorkflowStateManager
from kb_workflow.pipeline.processors.base import BaseProcessor
logger = logging.getLogger(__name__)
class ArchiveProcessor(BaseProcessor):
"""Archive 处理器 - 归档分析报告
处理流程:
1. 检查步骤是否已完成(幂等执行)
2. 检查归档目录是否存在相同 content_id 的文件
3. 如果存在重复,询问用户是否覆盖
4. 复制 md 文件到归档目录
5. 记录处理状态到 workflow_state.json
归档目录结构:
{KB_ARCHIVE_PATH}/{platform}/{YYYYMMDD}_{content_id}_analysis.md
"""
STEP_NAME = "archive"
async def process(self, input_data: ArchiveInput) -> ArchiveOutput:
"""执行归档处理
Args:
input_data: Archive 输入数据
Returns:
Archive 处理结果
"""
try:
# Step 1: 检查是否已完成(幂等执行)
if self.state_manager:
state = self.state_manager.load()
if state and state.is_step_completed(self.STEP_NAME):
logger.info(f"Step {self.STEP_NAME} already completed, skipping")
return ArchiveOutput(
success=True,
skipped=True,
platform=input_data.platform,
archive_file=""
)
# Step 2: 准备归档路径
archive_base = Path(self.settings.archive_path)
platform_dir = archive_base / input_data.platform
platform_dir.mkdir(parents=True, exist_ok=True)
# 文件命名: {YYYYMMDD}_{content_id}_analysis.md
timestamp = datetime.now().strftime("%Y%m%d")
archive_filename = f"{timestamp}_{input_data.content_id}_analysis.md"
archive_path = platform_dir / archive_filename
# Step 3: 检查重复
existing_file = self._find_existing_archive(
platform_dir, input_data.content_id
)
if existing_file:
should_overwrite = self._ask_overwrite(existing_file)
if not should_overwrite:
logger.info(f"User chose not to overwrite, skipping archive")
return ArchiveOutput(
success=True,
skipped=True,
platform=input_data.platform,
archive_file=str(existing_file)
)
# 删除旧文件
existing_file.unlink()
logger.info(f"Removed existing archive: {existing_file}")
# Step 4: 复制文件
source_path = Path(input_data.analysis_file)
if not source_path.exists():
raise FileNotFoundError(f"Analysis file not found: {source_path}")
shutil.copy2(source_path, archive_path)
logger.info(f"Archived: {source_path} -> {archive_path}")
# Step 5: 记录成功状态
if self.state_manager:
state = self.state_manager.load()
if state:
state.mark_step_completed(
self.STEP_NAME,
f"archived to {archive_path}"
)
self.state_manager.save(state)
return ArchiveOutput(
success=True,
skipped=False,
platform=input_data.platform,
archive_file=str(archive_path)
)
except Exception as e:
logger.error(f"Archive failed: {e}")
if self.state_manager:
state = self.state_manager.load()
if state:
state.mark_step_failed(self.STEP_NAME, str(e))
self.state_manager.save(state)
return ArchiveOutput(
success=False,
error_message=str(e),
platform=input_data.platform,
archive_file=""
)
def _find_existing_archive(self, platform_dir: Path, content_id: str) -> Optional[Path]:
"""查找已存在的归档文件
使用 glob 模式 `*_{content_id}_analysis.md` 搜索
Args:
platform_dir: 平台目录路径
content_id: 内容 ID
Returns:
已存在的文件路径,如果不存在返回 None
"""
pattern = f"*_{content_id}_analysis.md"
matches = list(platform_dir.glob(pattern))
return matches[0] if matches else None
def _ask_overwrite(self, existing_file: Path) -> bool:
"""询问用户是否覆盖已存在的文件
使用 rich.prompt.Confirm 模式(与 Pipeline._ask_rerun_step 相同)
Args:
existing_file: 已存在的文件路径
Returns:
True 如果用户选择覆盖
"""
try:
from rich.console import Console
from rich.prompt import Confirm
console = Console()
return Confirm.ask(
f"Archive already exists: {existing_file.name}\nOverwrite?",
default=False
)
except ImportError:
response = input(
f"Archive already exists: {existing_file.name}\nOverwrite? [y/N] "
)
return response.lower() in ('y', 'yes')
@@ -0,0 +1,685 @@
"""ASRProcessor - Audio-to-text conversion
Unified processor for B站官方字幕下载 and Whisper.cpp 语音识别.
Follows the pipeline pattern established in Phase 1/2.
Strategy:
1. Try official subtitle first (highest quality, no compute cost)
2. Fall back to Whisper.cpp if no subtitle available
3. Auto-enable speaker diarization if HF_TOKEN is configured
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import subprocess
from pathlib import Path
from typing import Optional, Dict, Any, List
import aiohttp
from kb_workflow.pipeline.config import get_settings
from kb_workflow.pipeline.schemas import ASRInput, ASROutput
from kb_workflow.pipeline.state import WorkflowStateManager
logger = logging.getLogger(__name__)
class WhisperNotFoundError(RuntimeError):
"""Whisper.cpp 路径未找到"""
pass
def get_whisper_cpp_path() -> str:
"""获取 whisper.cpp 安装路径
检查环境变量 WHISPER_CPP_PATH 或默认路径 ~/whisper.cpp
Returns:
whisper.cpp 目录路径
Raises:
WhisperNotFoundError: 如果路径不存在
"""
# 检查环境变量
path = os.environ.get('WHISPER_CPP_PATH')
if path and os.path.exists(path):
return os.path.expanduser(path)
# 检查默认路径
default = os.path.expanduser('~/whisper.cpp')
if os.path.exists(default):
return default
raise WhisperNotFoundError(
"未找到 whisper.cpp。请设置 WHISPER_CPP_PATH 环境变量或安装到 ~/whisper.cpp"
)
def convert_to_wav(input_path: str, output_path: Optional[str] = None) -> str:
"""使用 ffmpeg 将音频/视频转换为 16kHz WAV 格式
Args:
input_path: 输入文件路径
output_path: 输出文件路径(可选,默认为 input_path_audio.wav)
Returns:
输出 WAV 文件路径
"""
if output_path is None:
base = os.path.splitext(input_path)[0]
output_path = f"{base}_audio.wav"
cmd = [
'ffmpeg', '-y', '-i', input_path,
'-ar', '16000', # 16kHz 采样率
'-ac', '1', # 单声道
'-c:a', 'pcm_s16le', # 16-bit PCM
output_path
]
subprocess.run(cmd, capture_output=True, check=True)
return output_path
class ASRProcessor:
"""ASR 处理器 - 统一字幕下载和语音识别
处理流程:
1. 如果是 B站视频,尝试下载官方字幕
2. 无官方字幕则调用 Whisper.cpp
3. HF_TOKEN 配置时自动启用说话者分离
4. 记录处理状态到 workflow_state.json
Attributes:
STEP_NAME: 步骤名称,用于状态记录
settings: KB 配置
state_manager: 状态管理器(可选)
"""
STEP_NAME = "asr"
def __init__(self, state_manager: Optional[WorkflowStateManager] = None):
"""初始化 ASR 处理器
Args:
state_manager: 状态管理器(可选,用于记录处理状态)
"""
self.settings = get_settings()
self.state_manager = state_manager
async def process(self, input_data: ASRInput) -> ASROutput:
"""执行 ASR 处理
策略:
0. 先检查是否已有字幕文件(幂等性)
1. 先尝试官方字幕(仅 B站)
2. 无官方字幕则调用 Whisper
3. 记录状态
Args:
input_data: ASR 输入数据
Returns:
ASR 处理结果
Raises:
Exception: 处理失败时抛出异常
"""
result = None
try:
# Step 0: 检查是否已有字幕文件(幂等性)
existing_result = self._check_existing_subtitle_files(input_data)
if existing_result:
logger.info(f"Found existing subtitle files, skipping processing")
result = existing_result
else:
# Step 1: 尝试官方字幕(仅 B站)
if input_data.platform == "bilibili":
result = await self._try_official_subtitle(input_data)
# Step 2: 无官方字幕则调用 Whisper
if result is None:
result = await self._run_whisper(input_data)
# Step 3: 记录成功状态
if self.state_manager:
state = self.state_manager.load()
if state:
summary = f"tool={result.tool}, segments={len(result.segments)}"
if result.speakers:
summary += f", speakers={len(result.speakers)}"
state.mark_step_completed(self.STEP_NAME, summary)
self.state_manager.save(state)
return result
except Exception as e:
# 记录失败状态(只在所有重试都失败后)
if self.state_manager:
state = self.state_manager.load()
if state:
state.mark_step_failed(self.STEP_NAME, str(e))
self.state_manager.save(state)
raise
def _check_existing_subtitle_files(self, input_data: ASRInput) -> Optional[ASROutput]:
"""检查是否已存在字幕文件(幂等性检查)
Args:
input_data: ASR 输入数据
Returns:
如果找到现有字幕文件返回 ASROutput,否则返回 None
"""
output_dir = Path(input_data.content_dir)
# 尝试两种命名格式:新格式(带 _subtitle 后缀)和旧格式
base_names = [
f"{input_data.platform}_{input_data.content_id}_subtitle",
f"{input_data.platform}_{input_data.content_id}",
]
for base_name in base_names:
json_path = output_dir / f"{base_name}.json"
srt_path = output_dir / f"{base_name}.srt"
txt_path = output_dir / f"{base_name}.txt"
if json_path.exists():
# 读取现有字幕数据
segments = []
text = ""
duration = 0.0
try:
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# 解析字幕格式
if 'transcription' in data:
# Whisper 格式
for i, item in enumerate(data['transcription']):
offsets = item.get('offsets', {})
if isinstance(offsets, dict):
# 新格式:{"from": 0, "to": 5320}
start = offsets.get('from', 0) / 1000.0
end = offsets.get('to', 0) / 1000.0
else:
# 旧格式:[0, 5320]
start = offsets[0] / 1000.0 if len(offsets) > 0 else 0
end = offsets[1] / 1000.0 if len(offsets) > 1 else 0
segments.append({
'id': i,
'start': start,
'end': end,
'text': item.get('text', '')
})
elif 'body' in data:
# B站官方字幕格式
for i, item in enumerate(data['body']):
segments.append({
'id': i,
'start': item.get('from', 0),
'end': item.get('to', 0),
'text': item.get('content', '')
})
text = ' '.join(s['text'] for s in segments)
if segments:
duration = segments[-1]['end']
output_files = {'json': str(json_path)}
if srt_path.exists():
output_files['srt'] = str(srt_path)
if txt_path.exists():
output_files['txt'] = str(txt_path)
return ASROutput(
success=True,
tool="existing",
has_subtitle=True,
output_files=output_files,
segments=segments,
text=text,
duration_seconds=duration
)
except Exception as e:
logger.warning(f"Failed to read existing subtitle file: {e}")
continue
return None
async def _try_official_subtitle(self, input_data: ASRInput) -> Optional[ASROutput]:
"""尝试下载 B站官方字幕
Args:
input_data: ASR 输入数据
Returns:
如果找到字幕返回 ASROutput,否则返回 None
Note:
无字幕返回 None(不是抛异常),让流程继续到 Whisper
"""
# 使用 content_dir 作为输出目录(而不是从 video_path 推断)
output_dir = Path(input_data.content_dir)
# 使用 content_id 作为基础文件名
base_name = f"{input_data.platform}_{input_data.content_id}_subtitle"
# 输出文件路径
txt_path = output_dir / f"{base_name}.txt"
srt_path = output_dir / f"{base_name}.srt"
json_path = output_dir / f"{base_name}.json"
async with aiohttp.ClientSession() as session:
try:
# Step 1: 获取 CID
bvid = input_data.content_id
pagelist_url = f"https://api.bilibili.com/x/player/pagelist?bvid={bvid}"
async with session.get(pagelist_url) as resp:
pagelist_data = await resp.json()
pages = pagelist_data.get('data', [])
if not pages:
logger.info(f"No pagelist data for {bvid}")
return None
cid = pages[0].get('cid')
if not cid:
logger.info(f"No cid found for {bvid}")
return None
# Step 2: 获取字幕列表
player_url = f"https://api.bilibili.com/x/player/v2?bvid={bvid}&cid={cid}"
async with session.get(player_url) as resp:
player_data = await resp.json()
subtitle_info = player_data.get('data', {}).get('subtitle', {})
subtitles = subtitle_info.get('list', [])
if not subtitles:
logger.info(f"No official subtitle for {bvid}")
return None
# Step 3: 下载第一个字幕(优先 AI 生成的中文字幕)
subtitle_url = None
for sub in subtitles:
if sub.get('lan', '').startswith('zh'):
subtitle_url = sub.get('subtitle_url')
break
if not subtitle_url and subtitles:
subtitle_url = subtitles[0].get('subtitle_url')
if not subtitle_url:
return None
# 补全 URL
if subtitle_url.startswith('//'):
subtitle_url = f"https:{subtitle_url}"
async with session.get(subtitle_url) as resp:
subtitle_content = await resp.json()
# Step 4: 转换为输出格式
segments = []
text_parts = []
body = subtitle_content.get('body', [])
for i, item in enumerate(body):
start = item.get('from', 0)
end = item.get('to', 0)
content = item.get('content', '')
segments.append({
'id': i,
'start': start,
'end': end,
'text': content
})
text_parts.append(content)
full_text = ' '.join(text_parts)
duration = segments[-1]['end'] if segments else 0
# Step 5: 写入文件
# TXT 格式
txt_path.write_text(full_text, encoding='utf-8')
# SRT 格式
srt_content = self._convert_to_srt(segments)
srt_path.write_text(srt_content, encoding='utf-8')
# JSON 格式
json_data = {
'success': True,
'tool': 'subtitle',
'segments': segments,
'text': full_text,
'duration_seconds': duration
}
json_path.write_text(json.dumps(json_data, ensure_ascii=False, indent=2), encoding='utf-8')
return ASROutput(
success=True,
tool="subtitle",
has_subtitle=True,
output_files={
'txt': str(txt_path),
'srt': str(srt_path),
'json': str(json_path)
},
segments=segments,
text=full_text,
duration_seconds=duration
)
except aiohttp.ClientError as e:
logger.warning(f"Failed to fetch subtitle: {e}")
return None
def _convert_to_srt(self, segments: List[Dict]) -> str:
"""将字幕分段转换为 SRT 格式
Args:
segments: 字幕分段列表,每个包含 start, end, text
Returns:
SRT 格式字符串
"""
lines = []
for i, seg in enumerate(segments, 1):
start_time = self._seconds_to_srt_time(seg['start'])
end_time = self._seconds_to_srt_time(seg['end'])
lines.append(f"{i}")
lines.append(f"{start_time} --> {end_time}")
lines.append(seg['text'])
lines.append("") # 空行
return '\n'.join(lines)
def _seconds_to_srt_time(self, seconds: float) -> str:
"""将秒数转换为 SRT 时间格式 (HH:MM:SS,mmm)"""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
millis = int((seconds % 1) * 1000)
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
async def _run_whisper(self, input_data: ASRInput) -> ASROutput:
"""执行 Whisper 语音识别
根据 HF_TOKEN 配置自动选择普通模式或说话者分离模式。
Args:
input_data: ASR 输入数据
Returns:
ASR 处理结果
"""
hf_token = self.settings.hf_token.get_secret_value()
if hf_token:
return await self._run_whisper_with_diarization(input_data)
else:
return await self._run_whisper_basic(input_data)
async def _run_whisper_basic(self, input_data: ASRInput) -> ASROutput:
"""执行普通 Whisper.cpp 语音识别
Args:
input_data: ASR 输入数据
Returns:
ASR 处理结果
Raises:
RuntimeError: Whisper.cpp 路径不存在或执行失败,或 video_path 为空
"""
# 检查 video_path 是否有效
if not input_data.video_path:
raise RuntimeError(
"video_path 为空,无法执行 Whisper 语音识别。"
"请确保视频文件已下载(对于没有官方字幕的视频,需要先下载视频文件)。"
)
video_path = Path(input_data.video_path)
output_dir = Path(input_data.content_dir)
base_name = f"{input_data.platform}_{input_data.content_id}_whisper"
# 获取 Whisper.cpp 路径
try:
whisper_path = get_whisper_cpp_path()
except WhisperNotFoundError as e:
raise RuntimeError(str(e))
# 模型路径
model_path = os.path.join(whisper_path, 'models', 'ggml-base.bin')
if not os.path.exists(model_path):
raise RuntimeError(f"Whisper model not found: {model_path}")
# 转换音频格式
audio_path = convert_to_wav(str(video_path))
temp_audio_created = audio_path != str(video_path)
try:
# 构建 Whisper 命令
output_base = os.path.join(output_dir, base_name)
cmd = [
os.path.join(whisper_path, 'whisper-cli'),
'-m', model_path,
'-f', audio_path,
'-of', output_base,
'-l', 'zh',
'--output-txt', '--output-srt', '--output-json',
]
# 执行 Whisper
result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
if result.returncode != 0:
raise RuntimeError(f"Whisper failed: {result.stderr}")
# 解析输出
return self._parse_whisper_output(output_dir, base_name)
finally:
# 清理临时音频文件
if temp_audio_created and os.path.exists(audio_path):
try:
os.remove(audio_path)
except Exception:
pass # 静默失败,不阻塞主流程
def _parse_whisper_output(self, output_dir: Path, base_name: str) -> ASROutput:
"""解析 Whisper 输出文件
Args:
output_dir: 输出目录
base_name: 文件基础名
Returns:
ASR 处理结果
"""
txt_path = output_dir / f"{base_name}.txt"
srt_path = output_dir / f"{base_name}.srt"
json_path = output_dir / f"{base_name}.json"
# 读取 JSON 输出
segments = []
text = ""
duration = 0.0
if json_path.exists():
with open(json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# Whisper.cpp JSON 格式
if 'transcription' in data:
for i, item in enumerate(data['transcription']):
offsets = item.get('offsets', {})
if isinstance(offsets, dict):
# 新格式:{"from": 0, "to": 5320}
start = offsets.get('from', 0) / 1000.0
end = offsets.get('to', 0) / 1000.0
else:
# 旧格式:[0, 5320]
start = offsets[0] / 1000.0 if len(offsets) > 0 else 0
end = offsets[1] / 1000.0 if len(offsets) > 1 else 0
segments.append({
'id': i,
'start': start,
'end': end,
'text': item.get('text', '')
})
text = ' '.join(s['text'] for s in segments)
if segments:
duration = segments[-1]['end']
# 读取文本
if txt_path.exists():
text = txt_path.read_text(encoding='utf-8').strip()
return ASROutput(
success=True,
tool="whisper",
has_subtitle=False,
output_files={
'txt': str(txt_path),
'srt': str(srt_path),
'json': str(json_path)
},
segments=segments,
text=text,
duration_seconds=duration
)
async def _run_whisper_with_diarization(self, input_data: ASRInput) -> ASROutput:
"""执行带说话者分离的 Whisper
Args:
input_data: ASR 输入数据
Returns:
ASR 处理结果,包含说话者信息
"""
# 先运行普通 Whisper
basic_result = await self._run_whisper_basic(input_data)
try:
# 运行说话者分离
diarization_result = run_diarization(input_data.video_path)
# 合并结果
merged_segments = assign_speakers_to_segments(
basic_result.segments,
diarization_result
)
# 提取说话者列表
speakers = list(set(
seg.get('speaker') for seg in merged_segments if seg.get('speaker')
))
return ASROutput(
success=True,
tool="whisper-diarization",
has_subtitle=False,
output_files=basic_result.output_files,
segments=merged_segments,
text=basic_result.text,
duration_seconds=basic_result.duration_seconds,
speakers=sorted(speakers)
)
except ImportError:
logger.warning("pyannote.audio not available, falling back to basic whisper")
return basic_result
except Exception as e:
logger.warning(f"Diarization failed: {e}, falling back to basic whisper")
return basic_result
def run_diarization(audio_path: str, num_speakers: Optional[int] = None) -> Dict[str, List[Dict]]:
"""运行 pyannote.audio 说话者分离
Args:
audio_path: 音频文件路径
num_speakers: 预期说话者数量(可选)
Returns:
说话者时间线字典 {speaker_name: [{start, end}, ...]}
Raises:
ImportError: 如果 pyannote.audio 未安装
RuntimeError: 如果 HF_TOKEN 未配置
"""
hf_token = os.environ.get('HF_TOKEN')
if not hf_token:
raise RuntimeError("需要设置 HF_TOKEN 环境变量以使用说话者分离功能")
from huggingface_hub import login
from pyannote.audio import Pipeline
import torch
login(token=hf_token)
pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1")
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
pipeline.to(device)
diarization = pipeline(audio_path, num_speakers=num_speakers)
# 解析结果
speakers = {}
for turn, _, speaker in diarization.itertracks(yield_label=True):
if speaker not in speakers:
speakers[speaker] = []
speakers[speaker].append({
"start": round(turn.start, 2),
"end": round(turn.end, 2)
})
return speakers
def assign_speakers_to_segments(
segments: List[Dict],
diarization: Dict[str, List[Dict]]
) -> List[Dict]:
"""为每个段落分配说话者
根据时间戳匹配说话者分离结果到 Whisper 段落。
Args:
segments: Whisper 段落列表
diarization: 说话者分离结果
Returns:
带有 speaker 字段的段落列表
"""
result = []
for seg in segments:
seg_start = seg.get('start', 0)
seg_end = seg.get('end', 0)
seg_mid = (seg_start + seg_end) / 2
# 找到包含中点时间的说话者
assigned_speaker = None
for speaker, turns in diarization.items():
for turn in turns:
if turn['start'] <= seg_mid <= turn['end']:
assigned_speaker = speaker
break
if assigned_speaker:
break
new_seg = dict(seg)
new_seg['speaker'] = assigned_speaker
result.append(new_seg)
return result
@@ -0,0 +1,55 @@
"""BaseProcessor - Abstract base class for all pipeline processors
Provides a unified interface that all processors (parse, download, asr, analyze)
must follow, enabling consistent state management and error handling.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Optional, TYPE_CHECKING
from kb_workflow.pipeline.config import get_settings
from kb_workflow.pipeline.schemas import StepInput, StepOutput
if TYPE_CHECKING:
from kb_workflow.pipeline.state import WorkflowStateManager
class BaseProcessor(ABC):
"""Abstract base class for all pipeline processors.
All processors (ParseProcessor, DownloadProcessor, ASRProcessor, AnalyzeProcessor)
must inherit from this class and implement the process() method.
Attributes:
STEP_NAME: Class attribute identifying the processor step name.
settings: KBSettings instance from get_settings().
state_manager: Optional WorkflowStateManager for state recording.
"""
STEP_NAME: str
def __init__(self, state_manager: Optional[WorkflowStateManager] = None):
"""Initialize the processor.
Args:
state_manager: Optional WorkflowStateManager for recording step state.
"""
self.settings = get_settings()
self.state_manager = state_manager
@abstractmethod
async def process(self, input_data: StepInput) -> StepOutput:
"""Process the input data and return the result.
Args:
input_data: Input data for the processor step.
Returns:
StepOutput with processing results.
Raises:
Exception: If processing fails.
"""
pass
@@ -0,0 +1,338 @@
"""DownloadProcessor - Content download processor
Handles metadata extraction and content downloading for all platforms.
Works with PlatformAdapter implementations (BilibiliAdapter, ZhihuAdapter).
Purpose: Unify metadata and content fetching into a single processor
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Optional, TYPE_CHECKING, Dict, Any
from kb_workflow.pipeline.schemas import DownloadInput, DownloadOutput
from kb_workflow.pipeline.processors.base import BaseProcessor
if TYPE_CHECKING:
from kb_workflow.pipeline.state import WorkflowStateManager
from kb_workflow.pipeline.platforms.base import PlatformAdapter
logger = logging.getLogger(__name__)
class DownloadProcessor(BaseProcessor):
"""Content download processor.
Fetches metadata and content from platforms using PlatformAdapter.
Supports video, article, answer, and zhuanlan content types.
Attributes:
STEP_NAME: "download" - identifies this processor step
"""
STEP_NAME = "download"
async def process(self, input_data: DownloadInput) -> DownloadOutput:
"""Process the download request.
Args:
input_data: DownloadInput with content_id, platform, content_dir, content_type
Returns:
DownloadOutput with metadata_file, content_file, title, author, etc.
"""
content_dir = Path(input_data.content_dir)
content_dir.mkdir(parents=True, exist_ok=True)
# Check if step already completed (idempotency)
if self.state_manager:
state = self.state_manager.load()
if state and state.is_step_completed(self.STEP_NAME):
logger.info(f"Step {self.STEP_NAME} already completed, skipping")
# Return a success output based on existing files
return self._get_existing_output(input_data, content_dir)
# Get appropriate adapter
try:
adapter = self._get_adapter(input_data.platform)
except ValueError as e:
return self._record_failure(str(e))
try:
# Fetch metadata (pass source_url for zhihu answers)
if input_data.platform == "zhihu":
metadata = await adapter.fetch_metadata(
input_data.content_id,
input_data.content_type,
source_url=input_data.source_url
)
else:
metadata = await adapter.fetch_metadata(
input_data.content_id,
input_data.content_type
)
# Save metadata
metadata_filename = adapter.get_output_filename(
input_data.content_id,
input_data.content_type,
"metadata.json"
)
metadata_path = content_dir / metadata_filename
self._save_metadata(metadata, metadata_path)
# Extract title and author from metadata
title = self._extract_title(metadata, input_data.platform)
author = self._extract_author(metadata, input_data.platform)
duration = self._extract_duration(metadata, input_data.content_type)
# Fetch content (for text-based content)
content_file = ""
if input_data.content_type in ["article", "answer", "zhuanlan"]:
if input_data.platform == "zhihu":
content = await adapter.fetch_content(
input_data.content_id,
input_data.content_type,
source_url=input_data.source_url
)
else:
content = await adapter.fetch_content(
input_data.content_id,
input_data.content_type
)
content_filename = adapter.get_output_filename(
input_data.content_id,
input_data.content_type,
"content.md"
)
content_path = content_dir / content_filename
self._save_content(content, content_path)
content_file = str(content_path)
# Download video for ASR (Whisper needs video file)
if input_data.content_type == "video" and input_data.platform == "bilibili":
download_result = await adapter.download_video(
input_data.content_id,
content_dir
)
if download_result["success"]:
content_file = download_result["filepath"]
logger.info(f"Video downloaded for ASR: {content_file}")
else:
logger.warning(f"Video download failed: {download_result['message']}")
# Record success
result = DownloadOutput(
success=True,
metadata_file=str(metadata_path),
content_file=content_file,
title=title,
author=author,
duration=duration
)
if self.state_manager:
state = self.state_manager.load()
if state:
summary = f"title={title}, type={input_data.content_type}"
state.mark_step_completed(self.STEP_NAME, summary)
self.state_manager.save(state)
return result
except Exception as e:
logger.error(f"Download failed: {e}")
return self._record_failure(str(e))
def _get_adapter(self, platform: str) -> PlatformAdapter:
"""Get the appropriate PlatformAdapter for the platform.
Args:
platform: Platform name ("bilibili" or "zhihu")
Returns:
PlatformAdapter instance
Raises:
ValueError: If platform is not supported
"""
if platform == "bilibili":
from kb_workflow.pipeline.platforms.bilibili import BilibiliAdapter
return BilibiliAdapter(state_manager=self.state_manager)
elif platform == "zhihu":
from kb_workflow.pipeline.platforms.zhihu import ZhihuAdapter
return ZhihuAdapter()
else:
raise ValueError(f"Unsupported platform: {platform}")
def _save_metadata(self, metadata: Dict[str, Any], path: Path) -> None:
"""Save metadata to JSON file.
Args:
metadata: Metadata dictionary
path: Output file path
"""
with open(path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, ensure_ascii=False, indent=2)
logger.info(f"Saved metadata to {path}")
def _save_content(self, content: str, path: Path) -> None:
"""Save content to Markdown file.
Args:
content: Markdown content string
path: Output file path
"""
with open(path, 'w', encoding='utf-8') as f:
f.write(content)
logger.info(f"Saved content to {path}")
def _extract_title(self, metadata: Dict[str, Any], platform: str) -> str:
"""Extract title from metadata.
Args:
metadata: Metadata dictionary
platform: Platform name
Returns:
Title string
"""
if platform == "bilibili":
# Bilibili metadata structure: {metadata: {title: ...}, ...}
if "metadata" in metadata:
return metadata["metadata"].get("title", "")
return metadata.get("title", "")
else:
return metadata.get("title", "")
def _extract_author(self, metadata: Dict[str, Any], platform: str) -> str:
"""Extract author from metadata.
Args:
metadata: Metadata dictionary
platform: Platform name
Returns:
Author string (author name, not mid)
"""
if platform == "bilibili":
# Bilibili metadata structure: {metadata: {author: ..., mid: ...}, ...}
if "metadata" in metadata:
return str(metadata["metadata"].get("author", ""))
return str(metadata.get("author", ""))
else:
return metadata.get("author", "")
def _extract_duration(self, metadata: Dict[str, Any], content_type: str) -> Optional[int]:
"""Extract duration from metadata for video content.
Args:
metadata: Metadata dictionary
content_type: Content type
Returns:
Duration in seconds for videos, None otherwise
"""
if content_type != "video":
return None
# Bilibili video duration
if "metadata" in metadata and "duration" in metadata["metadata"]:
duration = metadata["metadata"]["duration"]
# Handle formatted duration string (e.g., "12:34" or "01:23:45")
if isinstance(duration, str):
if not duration.strip():
return None
parts = duration.split(':')
try:
if len(parts) == 2:
return int(parts[0]) * 60 + int(parts[1])
elif len(parts) == 3:
return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
except (ValueError, IndexError):
return None
elif isinstance(duration, (int, float)):
return int(duration)
return None
def _record_failure(self, error_message: str) -> DownloadOutput:
"""Record failure to state manager and return failure output.
Args:
error_message: Error message
Returns:
DownloadOutput with success=False
"""
if self.state_manager:
state = self.state_manager.load()
if state:
state.mark_step_failed(self.STEP_NAME, error_message)
self.state_manager.save(state)
return DownloadOutput(
success=False,
error_message=error_message,
metadata_file="",
content_file="",
title="",
author=""
)
def _get_existing_output(
self,
input_data: DownloadInput,
content_dir: Path
) -> DownloadOutput:
"""Get output from existing files when step was already completed.
Args:
input_data: DownloadInput
content_dir: Content directory path
Returns:
DownloadOutput based on existing files
"""
adapter = self._get_adapter(input_data.platform)
metadata_filename = adapter.get_output_filename(
input_data.content_id,
input_data.content_type,
"metadata.json"
)
metadata_path = content_dir / metadata_filename
# Try to read metadata for title/author
title = ""
author = ""
duration = None
if metadata_path.exists():
try:
with open(metadata_path, 'r', encoding='utf-8') as f:
metadata = json.load(f)
title = self._extract_title(metadata, input_data.platform)
author = self._extract_author(metadata, input_data.platform)
duration = self._extract_duration(metadata, input_data.content_type)
except Exception:
pass
content_filename = adapter.get_output_filename(
input_data.content_id,
input_data.content_type,
"content.md"
)
content_path = content_dir / content_filename
content_file = str(content_path) if content_path.exists() else ""
return DownloadOutput(
success=True,
metadata_file=str(metadata_path),
content_file=content_file,
title=title,
author=author,
duration=duration
)
@@ -0,0 +1,83 @@
"""ParseProcessor - URL parsing processor
Parses URLs from various platforms (Bilibili, Zhihu) and extracts
platform, content type, and content ID information.
"""
from __future__ import annotations
from typing import Optional, TYPE_CHECKING
from kb_workflow.pipeline.config import get_settings
from kb_workflow.pipeline.link_parser import LinkParser
from kb_workflow.pipeline.schemas import ParseInput, ParseOutput
from kb_workflow.pipeline.processors.base import BaseProcessor
if TYPE_CHECKING:
from kb_workflow.pipeline.state import WorkflowStateManager
class ParseProcessor(BaseProcessor):
"""URL parsing processor.
Parses URLs from Bilibili and Zhihu platforms, extracting
platform, content type, and content ID.
Attributes:
STEP_NAME: "parse" - identifies this processor step
"""
STEP_NAME = "parse"
async def process(self, input_data: ParseInput) -> ParseOutput:
"""Parse the URL and extract platform information.
Args:
input_data: ParseInput with URL to parse
Returns:
ParseOutput with parsed information or error message
"""
parser = LinkParser()
parsed = parser.parse(input_data.url)
if parsed is None:
# Unknown URL format
result = ParseOutput(
success=False,
error_message="Unsupported URL format",
platform="",
content_type="",
content_id="",
resolved_url="",
original_url=input_data.url
)
# Record failure state
if self.state_manager:
state = self.state_manager.load()
if state:
state.mark_step_failed(self.STEP_NAME, result.error_message)
self.state_manager.save(state)
return result
# Successful parse
result = ParseOutput(
success=True,
platform=parsed.platform,
content_type=parsed.content_type,
content_id=parsed.content_id,
resolved_url=parsed.resolved_url,
original_url=parsed.original_url
)
# Record success state
if self.state_manager:
state = self.state_manager.load()
if state:
summary = f"platform={parsed.platform}, type={parsed.content_type}, id={parsed.content_id}"
state.mark_step_completed(self.STEP_NAME, summary)
self.state_manager.save(state)
return result
+51
View File
@@ -0,0 +1,51 @@
"""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
"""
+446
View File
@@ -0,0 +1,446 @@
"""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")
+152
View File
@@ -0,0 +1,152 @@
"""
WorkflowStateManager - 步骤级状态持久化管理器
提供工作流状态的原子写入、备份轮转和崩溃恢复功能。
Purpose: 为工作流提供可靠的状态管理,支持断点续传和崩溃恢复
"""
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from typing import Optional
from kb_workflow.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
)