自动同步: 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
@@ -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}"