自动同步: 2026-03-18 02:10
This commit is contained in:
@@ -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}"
|
||||
Reference in New Issue
Block a user