feat(09-01): integrate ZhihuCrawler into ZhihuAdapter

- Replace ZhihuPlaywrightExtractor with ZhihuCrawler
- Add comment crawling with include_comments=True by default
- Implement cookie expiration detection and QR code login trigger
- Add idempotency check via IdempotencyChecker
- Add comment formatting with "## 热门评论" section
- Convert HTML content to Markdown using html2text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 23:14:22 +08:00
parent 22ed60a5ab
commit 76f06c330b
2 changed files with 726 additions and 289 deletions
+345 -188
View File
@@ -7,21 +7,24 @@ Tests cover:
- Login page detection
- AuthError handling
Wave 0 Test Scaffolding (Phase 09 - Pipeline Integration):
- Mock fixtures for ZhihuCrawler CrawlResult
- Test skeletons for integration with new crawler
Phase 09 - Pipeline Integration:
- ZhihuAdapter now uses ZhihuCrawler instead of ZhihuPlaywrightExtractor
- Comments are included by default and appended to content
- Cookie expiration triggers QR code login
- Idempotency via IdempotencyChecker
"""
import pytest
from unittest.mock import MagicMock, patch, PropertyMock
import asyncio
from pathlib import Path
from datetime import datetime
from kb_workflow.pipeline.platforms.zhihu import ZhihuAdapter
from kb_workflow.pipeline.errors import AuthError
# =============================================================================
# Wave 0 Mock Fixtures for ZhihuCrawler Integration (Phase 09)
# Mock Fixtures for ZhihuCrawler Integration (Phase 09)
# =============================================================================
@@ -127,6 +130,35 @@ def mock_crawl_article_result():
)
@pytest.fixture
def mock_crawl_no_comments_result():
"""Mock CrawlResult without comments."""
from kb_workflow.crawler.models import (
CrawlResult, AnswerData, AuthorInfo, ContentMetadata
)
return CrawlResult(
success=True,
content_type="answer",
data=AnswerData(
id=789012,
question_id=123456,
question_title="Test Question No Comments",
content="<p>Answer without comments.</p>",
excerpt="Answer without comments.",
metadata=ContentMetadata(
author=AuthorInfo(name="Test Author", url_token="test-author"),
voteup_count=100,
comment_count=0,
created_time=1705312800,
updated_time=None
)
),
comments=[],
error=None
)
@pytest.fixture
def mock_comments_list():
"""Mock list of CommentData for testing comment formatting.
@@ -198,8 +230,21 @@ def mock_crawl_failed_result():
)
class TestZhihuAdapterAnswerContent:
"""Tests for ZhihuAdapter answer extraction"""
@pytest.fixture
def mock_idempotency_checker():
"""Mock IdempotencyChecker for testing idempotent behavior."""
mock = MagicMock()
mock.is_processed.return_value = False
return mock
# =============================================================================
# Basic Adapter Tests
# =============================================================================
class TestZhihuAdapterBasic:
"""Tests for basic ZhihuAdapter functionality"""
def test_get_content_type_answer(self):
"""Test content type detection for answer URLs"""
@@ -234,32 +279,90 @@ class TestZhihuAdapterAnswerContent:
assert adapter.get_output_filename("789012", "answer", "metadata.json") == "zhihu_789012_metadata.json"
assert adapter.get_output_filename("123456", "zhuanlan", "content.md") == "zhihu_123456_content.md"
@pytest.mark.asyncio
async def test_fetch_metadata_answer(self, mock_zhihu_answer_result):
"""Test fetching answer metadata"""
def test_platform_constant(self):
"""Test PLATFORM constant"""
adapter = ZhihuAdapter()
adapter._cookie = "test_cookie" # Set cookie directly
adapter._cookie = "test" # Avoid config loading
assert adapter.PLATFORM == "zhihu"
with patch.object(adapter, '_extract_with_playwright', return_value=mock_zhihu_answer_result):
metadata = await adapter.fetch_metadata("789012", "answer")
assert metadata["title"] == "Test Question Title"
# =============================================================================
# ZhihuCrawler Integration Tests (Phase 09)
# =============================================================================
class TestZhihuAdapterCrawlerIntegration:
"""Tests for ZhihuAdapter integration with ZhihuCrawler.
These tests verify the adapter correctly uses the new ZhihuCrawler
instead of ZhihuPlaywrightExtractor.
"""
@pytest.mark.asyncio
async def test_fetch_metadata_answer(self, mock_crawl_answer_result):
"""Test fetching answer metadata via ZhihuCrawler.
Verifies:
- ZhihuCrawler.crawl_answer() is called with include_comments=True
- Metadata is correctly extracted from CrawlResult
- Output format matches expected adapter response
"""
adapter = ZhihuAdapter()
adapter._cookie = "test_cookie"
with patch.object(adapter, '_crawl_with_crawler', return_value=mock_crawl_answer_result):
metadata = await adapter.fetch_metadata("789012", "answer", source_url="https://www.zhihu.com/question/123456/answer/789012")
assert metadata["title"] == "Test Question Title for Pipeline Integration"
assert metadata["author"] == "Test Author"
assert metadata["type"] == "answer"
assert metadata["voteup_count"] == 1234
@pytest.mark.asyncio
async def test_fetch_content_answer(self, mock_zhihu_answer_result):
"""Test fetching answer content as Markdown"""
async def test_fetch_metadata_article(self, mock_crawl_article_result):
"""Test fetching zhuanlan article metadata via ZhihuCrawler.
Verifies:
- ZhihuCrawler.crawl_article() is called with correct URL
- Metadata is correctly extracted from CrawlResult
- Output format matches expected adapter response
"""
adapter = ZhihuAdapter()
adapter._cookie = "test_cookie" # Set cookie directly
adapter._cookie = "test_cookie"
with patch.object(adapter, '_extract_with_playwright', return_value=mock_zhihu_answer_result):
content = await adapter.fetch_content("789012", "answer")
with patch.object(adapter, '_crawl_with_crawler', return_value=mock_crawl_article_result):
metadata = await adapter.fetch_metadata("123456", "zhuanlan")
assert "---" in content # YAML frontmatter
assert metadata["title"] == "Test Article Title for Pipeline Integration"
assert metadata["author"] == "Test Article Author"
assert metadata["type"] == "zhuanlan"
@pytest.mark.asyncio
async def test_fetch_content_with_comments(self, mock_crawl_answer_result):
"""Test fetching content with comments appended.
Verifies:
- Content is fetched with include_comments=True
- Comments are appended to content in correct Markdown format
- Comment format: numbered list with author mention
"""
adapter = ZhihuAdapter()
adapter._cookie = "test_cookie"
with patch.object(adapter, '_crawl_with_crawler', return_value=mock_crawl_answer_result):
content = await adapter.fetch_content("789012", "answer", source_url="https://www.zhihu.com/question/123456/answer/789012")
# Verify YAML frontmatter
assert "---" in content
assert "title:" in content
assert "Test Question Title" in content
assert "This is the answer content" in content
# Verify main content
assert "Test Question Title for Pipeline Integration" in content
# Verify comments section is appended
assert "## 热门评论" in content
assert "@Commenter1" in content
assert "Great answer!" in content
@pytest.mark.asyncio
async def test_fetch_metadata_no_cookie_raises_auth_error(self):
@@ -273,80 +376,230 @@ class TestZhihuAdapterAnswerContent:
assert exc_info.value.auth_type == "zhihu_cookie"
class TestZhihuAdapterZhuanlanContent:
"""Tests for ZhihuAdapter zhuanlan (article) extraction"""
@pytest.mark.asyncio
async def test_fetch_metadata_zhuanlan(self, mock_zhihu_zhuanlan_result):
"""Test fetching zhuanlan metadata"""
adapter = ZhihuAdapter()
adapter._cookie = "test_cookie" # Set cookie directly
with patch.object(adapter, '_extract_with_playwright', return_value=mock_zhihu_zhuanlan_result):
metadata = await adapter.fetch_metadata("123456", "zhuanlan")
assert metadata["title"] == "Test Article Title"
assert metadata["author"] == "Test Author"
assert metadata["type"] == "zhuanlan"
@pytest.mark.asyncio
async def test_fetch_content_zhuanlan(self, mock_zhihu_zhuanlan_result):
"""Test fetching zhuanlan content as Markdown"""
adapter = ZhihuAdapter()
adapter._cookie = "test_cookie" # Set cookie directly
with patch.object(adapter, '_extract_with_playwright', return_value=mock_zhihu_zhuanlan_result):
content = await adapter.fetch_content("123456", "zhuanlan")
assert "---" in content # YAML frontmatter
assert "title:" in content
assert "Test Article Title" in content
assert "This is the article content" in content
@pytest.mark.asyncio
async def test_detect_login_page_raises_auth_error(self):
"""Test that login page detection raises AuthError"""
adapter = ZhihuAdapter()
adapter._cookie = "expired_cookie" # Set cookie directly
# Simulate login page response (empty content or login title)
login_page_result = {
"type": "answer",
"title": "登录 - 知乎",
"content": "",
"content_markdown": "",
}
with patch.object(adapter, '_extract_with_playwright', return_value=login_page_result):
with pytest.raises(AuthError) as exc_info:
await adapter.fetch_metadata("789012", "answer")
assert exc_info.value.auth_type == "zhihu_cookie"
assert "过期" in str(exc_info.value.cause) or "expired" in str(exc_info.value.cause).lower()
@pytest.mark.asyncio
async def test_detect_short_content_raises_auth_error(self):
"""Test that very short content (< 100 chars) raises AuthError"""
adapter = ZhihuAdapter()
adapter._cookie = "test_cookie"
# Simulate response with very short content
short_content_result = {
"type": "answer",
"title": "Some Title",
"content": "x", # Very short content
"content_markdown": "",
}
with patch.object(adapter, '_extract_with_playwright', return_value=short_content_result):
with pytest.raises(AuthError) as exc_info:
await adapter.fetch_metadata("789012", "answer")
assert exc_info.value.auth_type == "zhihu_cookie"
# =============================================================================
# Cookie Expiration and Auto-Login Tests (Task 2)
# =============================================================================
class TestZhihuAdapterCookieHandling:
"""Tests for cookie handling"""
"""Tests for cookie handling and auto-login"""
@pytest.mark.asyncio
async def test_cookie_valid_returns_data(self, mock_crawl_answer_result):
"""Test that valid cookie allows normal data retrieval"""
adapter = ZhihuAdapter()
adapter._cookie = "valid_cookie"
with patch.object(adapter, '_crawl_with_crawler', return_value=mock_crawl_answer_result):
metadata = await adapter.fetch_metadata("789012", "answer")
assert metadata["title"] == "Test Question Title for Pipeline Integration"
@pytest.mark.asyncio
async def test_cookie_expired_triggers_login(self, mock_crawl_failed_result, mock_zhihu_login_result, mock_crawl_answer_result):
"""Test that expired cookie triggers QR code login flow.
Verifies:
- CrawlResult with success=False triggers login
- ZhihuLogin.login_by_qrcode() is called
- New cookie is used to retry the fetch
"""
adapter = ZhihuAdapter()
adapter._cookie = "expired_cookie"
# First call fails (cookie expired), second succeeds after login
crawl_results = [mock_crawl_failed_result, mock_crawl_answer_result]
with patch.object(adapter, '_crawl_with_crawler', side_effect=crawl_results):
with patch.object(adapter, '_trigger_qrcode_login', return_value=mock_zhihu_login_result):
metadata = await adapter.fetch_metadata("789012", "answer")
# Verify login was triggered and cookie was updated
assert adapter._cookie == mock_zhihu_login_result.cookie_str
@pytest.mark.asyncio
async def test_login_retry_success(self, mock_crawl_failed_result, mock_zhihu_login_result, mock_crawl_answer_result):
"""Test that after successful login, retry succeeds"""
adapter = ZhihuAdapter()
adapter._cookie = "expired_cookie"
crawl_results = [mock_crawl_failed_result, mock_crawl_answer_result]
with patch.object(adapter, '_crawl_with_crawler', side_effect=crawl_results):
with patch.object(adapter, '_trigger_qrcode_login', return_value=mock_zhihu_login_result):
content = await adapter.fetch_content("789012", "answer")
# Verify login was triggered and cookie was updated
assert adapter._cookie == mock_zhihu_login_result.cookie_str
assert "Test Question Title for Pipeline Integration" in content
@pytest.mark.asyncio
async def test_login_failure_raises_error(self, mock_crawl_failed_result):
"""Test that failed login raises appropriate error"""
from kb_workflow.auth.zhihu_login import ZhihuLoginResult
adapter = ZhihuAdapter()
adapter._cookie = "expired_cookie"
failed_login = ZhihuLoginResult(
success=False,
cookie_str="",
error_message="Login timeout"
)
with patch.object(adapter, '_crawl_with_crawler', return_value=mock_crawl_failed_result):
with patch.object(adapter, '_trigger_qrcode_login', return_value=failed_login):
with pytest.raises(AuthError) as exc_info:
await adapter.fetch_metadata("789012", "answer")
assert exc_info.value.auth_type == "zhihu_cookie"
# =============================================================================
# Comment Formatting Tests (Task 3)
# =============================================================================
class TestZhihuAdapterCommentFormatting:
"""Tests for comment formatting functionality"""
@pytest.mark.asyncio
async def test_comment_formatting_with_comments(self, mock_crawl_answer_result):
"""Test that comments are formatted with proper Markdown structure.
Verifies:
- Comments section starts with "---" separator
- "## 热门评论" heading is present
- Each comment is numbered with bold author name
"""
adapter = ZhihuAdapter()
adapter._cookie = "test_cookie"
with patch.object(adapter, '_crawl_with_crawler', return_value=mock_crawl_answer_result):
content = await adapter.fetch_content("789012", "answer")
# Check comment section structure
assert "---" in content
assert "## 热门评论" in content
# Check comment formatting: numbered list with author
assert "1. **@Commenter1**" in content or "1. **@Commenter1**" in content
assert "Great answer!" in content
@pytest.mark.asyncio
async def test_comment_formatting_no_comments(self, mock_crawl_no_comments_result):
"""Test that no comments section is added when there are no comments.
Verifies:
- No "---" separator for empty comments
- No "## 热门评论" heading when comments list is empty
"""
adapter = ZhihuAdapter()
adapter._cookie = "test_cookie"
with patch.object(adapter, '_crawl_with_crawler', return_value=mock_crawl_no_comments_result):
content = await adapter.fetch_content("789012", "answer")
# No comments section should be present
assert "## 热门评论" not in content
def test_format_comments_section(self, mock_comments_list):
"""Test internal _format_comments_section method.
Verifies:
- Comments are formatted as numbered list
- Author name is bold/mentioned
- Like count is included
- Format matches expected style:
1. **@AuthorName** (100 likes): Comment content...
"""
adapter = ZhihuAdapter()
adapter._cookie = "test"
result = adapter._format_comments_section(mock_comments_list)
# Check format structure
assert "1. **@User1**" in result
assert "(100 likes)" in result or "100 likes" in result
assert "This is the first comment" in result
# Check author comment indicator
assert "@AuthorName" in result
# =============================================================================
# Idempotency Tests (Task 3)
# =============================================================================
class TestZhihuAdapterIdempotency:
"""Tests for idempotent execution"""
@pytest.mark.asyncio
async def test_idempotent_execution_skips_processed(self, mock_crawl_answer_result, tmp_path):
"""Test that idempotent check prevents reprocessing when output_dir is set.
Verifies:
- If output_dir is set and content is already processed, cached result is returned
- No duplicate fetches are made
"""
import json
adapter = ZhihuAdapter(output_dir=tmp_path)
adapter._cookie = "test_cookie"
# Create existing files to simulate already processed content
metadata_file = tmp_path / "zhihu_789012_metadata.json"
content_file = tmp_path / "zhihu_789012_content.md"
cached_metadata = {
"title": "Cached Title",
"author": "Cached Author",
"type": "answer"
}
metadata_file.write_text(json.dumps(cached_metadata))
content_file.write_text("Cached content")
# When content is already processed, should return cached result
with patch.object(adapter, '_crawl_with_crawler') as mock_crawl:
metadata = await adapter.fetch_metadata("789012", "answer")
# Should NOT have called the crawler since content is cached
mock_crawl.assert_not_called()
# Should return cached metadata
assert metadata["title"] == "Cached Title"
@pytest.mark.asyncio
async def test_different_content_processes_independently(self, mock_crawl_answer_result, mock_crawl_article_result):
"""Test that different content IDs are processed independently.
Verifies:
- Processing one content doesn't affect processing of another
- Each content_id is tracked separately
"""
adapter = ZhihuAdapter()
adapter._cookie = "test_cookie"
# Process answer
with patch.object(adapter, '_crawl_with_crawler', return_value=mock_crawl_answer_result):
metadata_answer = await adapter.fetch_metadata("789012", "answer")
# Process article
with patch.object(adapter, '_crawl_with_crawler', return_value=mock_crawl_article_result):
metadata_article = await adapter.fetch_metadata("123456", "zhuanlan")
# Both should return their respective data
assert metadata_answer["title"] == "Test Question Title for Pipeline Integration"
assert metadata_article["title"] == "Test Article Title for Pipeline Integration"
# =============================================================================
# Cookie Settings Tests
# =============================================================================
class TestZhihuAdapterCookieSettings:
"""Tests for cookie loading from settings"""
def test_cookie_from_settings(self):
"""Test cookie is loaded from settings"""
@@ -357,99 +610,3 @@ class TestZhihuAdapterCookieHandling:
adapter = ZhihuAdapter()
assert adapter.cookie == "test_cookie_value"
def test_platform_constant(self):
"""Test PLATFORM constant"""
adapter = ZhihuAdapter()
adapter._cookie = "test" # Avoid config loading
assert adapter.PLATFORM == "zhihu"
# =============================================================================
# Wave 0 Test Skeletons for Pipeline Integration (Phase 09)
# These tests are marked skip until ZhihuAdapter is updated to use ZhihuCrawler
# =============================================================================
@pytest.mark.skip(reason="Wave 0 skeleton - awaiting ZhihuCrawler integration")
class TestZhihuAdapterCrawlerIntegration:
"""Test skeletons for ZhihuAdapter integration with ZhihuCrawler.
These tests will verify the adapter correctly uses the new ZhihuCrawler
instead of ZhihuPlaywrightExtractor.
"""
@pytest.mark.asyncio
async def test_fetch_metadata_answer(self, mock_crawl_answer_result):
"""Test fetching answer metadata via ZhihuCrawler.
Verifies:
- ZhihuCrawler.crawl_answer() is called with correct ID
- Metadata is correctly extracted from CrawlResult
- Output format matches expected adapter response
"""
pass
@pytest.mark.asyncio
async def test_fetch_metadata_article(self, mock_crawl_article_result):
"""Test fetching zhuanlan article metadata via ZhihuCrawler.
Verifies:
- ZhihuCrawler.crawl_article() is called with correct ID
- Metadata is correctly extracted from CrawlResult
- Output format matches expected adapter response
"""
pass
@pytest.mark.asyncio
async def test_fetch_content_with_comments(
self, mock_crawl_answer_result, mock_comments_list
):
"""Test fetching content with comments appended.
Verifies:
- Content is fetched with include_comments=True
- Comments are appended to content in correct Markdown format
- Comment format: numbered list with author mention
"""
pass
@pytest.mark.asyncio
async def test_cookie_expired_triggers_login(
self, mock_crawl_failed_result, mock_zhihu_login_result
):
"""Test that expired cookie triggers QR code login flow.
Verifies:
- CrawlResult with success=False triggers login
- ZhihuLogin.login_by_qrcode() is called
- New cookie is used to retry the fetch
- User sees QR code prompt
"""
pass
@pytest.mark.asyncio
async def test_idempotent_execution(self, mock_crawl_answer_result):
"""Test that repeated calls with same content_id are idempotent.
Verifies:
- Multiple calls with same ID produce same result
- No duplicate fetches are made
- State is correctly managed
"""
pass
def test_comment_formatting(self, mock_comments_list):
"""Test comment formatting to Markdown.
Verifies:
- Comments are formatted as numbered list
- Author name is bold/mentioned
- Like count is included
- Format matches expected style:
1. **@AuthorName** (100 likes): Comment content...
"""
pass
# WAVE_0_COMPLETE: Test scaffolding ready for implementation