This repository has been archived on 2026-08-07. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
kb-skills/.planning/phases/08-crawler/08-02-PLAN.md
T

476 lines
15 KiB
Markdown

---
phase: 08-crawler
plan: 02
type: execute
wave: 2
depends_on: [08-01]
files_modified:
- kb_workflow/crawler/zhihu_crawler.py
- kb_workflow/crawler/models.py
- tests/test_crawler/test_comment_crawler.py
autonomous: true
requirements: [CONT-03]
user_setup: []
must_haves:
truths:
- "用户可以选择是否爬取内容下的评论"
- "爬取的评论是热门评论(非全部评论)"
- "只爬取第一层评论,不展开嵌套回复"
artifacts:
- path: "kb_workflow/crawler/models.py"
provides: "CommentData 数据模型"
contains: "class CommentData"
- path: "kb_workflow/crawler/zhihu_crawler.py"
provides: "crawl_comments() 方法"
contains: "def crawl_comments"
- path: "tests/test_crawler/test_comment_crawler.py"
provides: "评论爬取测试"
exports: ["test_crawl_comments"]
key_links:
- from: "ZhihuCrawler.crawl_comments"
to: "/api/v4/comment_v5/{type}/{id}/comments"
via: "热门评论 API"
pattern: "comment_v5.*order=hot"
---
<objective>
实现知乎评论爬取功能,通过热门评论 API 获取第一层评论,作为可选功能集成到 ZhihuCrawler。
Purpose: 提供可选的评论爬取能力,丰富内容元数据
Output: crawl_comments() 方法 + CommentData 模型 + 测试
</objective>
<execution_context>
@/home/starmen1265/.claude/get-shit-done/workflows/execute-plan.md
@/home/starmen1265/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-crawler/08-CONTEXT.md
@.planning/phases/08-crawler/08-RESEARCH.md
@.planning/phases/08-crawler/08-01-SUMMARY.md
<interfaces>
<!-- 热门评论 API (from RESEARCH.md) -->
评论 API 格式:
```
https://www.zhihu.com/api/v4/comment_v5/{content_type}/{content_id}/comments
?order=hot&limit=20
```
参数说明:
- content_type: "answers" (回答) 或 "articles" (文章)
- content_id: 内容 ID
- order: "hot" (热门) 或 "ts" (最新)
- limit: 每页数量,默认 20
响应结构 (预期):
```json
{
"data": [
{
"id": 12345,
"content": "评论内容",
"author": {"name": "用户名"},
"created_time": 1234567890,
"like_count": 10,
"child_comment_count": 2
}
],
"paging": {...}
}
```
<!-- 08-01 创建的接口 (此计划依赖) -->
From kb_workflow/crawler/models.py (created by 08-01):
```python
class AnswerData(BaseModel):
id: int
question_id: int
question_title: str
content: str
excerpt: str
metadata: ContentMetadata
class ArticleData(BaseModel):
id: int
title: str
content: str
excerpt: str
metadata: ContentMetadata
class CrawlResult(BaseModel):
success: bool
content_type: str
data: Optional[AnswerData | ArticleData] = None
error: Optional[str] = None
```
From kb_workflow/crawler/zhihu_crawler.py (created by 08-01):
```python
class ZhihuCrawler:
def crawl_answer(self, url: str) -> CrawlResult
def crawl_article(self, url: str) -> CrawlResult
def _get_cookie(self) -> str
def _parse_metadata(self, data: Dict) -> ContentMetadata
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: 添加 CommentData 数据模型</name>
<files>kb_workflow/crawler/models.py</files>
<action>
在 models.py 中添加评论相关的数据模型。
**添加到 kb_workflow/crawler/models.py:**
```python
class CommentAuthor(BaseModel):
"""评论作者信息"""
name: str = Field(..., description="作者名称")
url_token: Optional[str] = Field(None, description="作者 URL token")
class CommentData(BaseModel):
"""评论数据"""
id: int
content: str = Field(..., description="评论内容")
author: Optional[CommentAuthor] = None
created_time: Optional[int] = Field(None, description="创建时间戳")
like_count: int = Field(default=0, description="点赞数")
child_count: int = Field(default=0, description="子评论数")
is_author: bool = Field(default=False, description="是否为作者回复")
class CommentsResult(BaseModel):
"""评论爬取结果"""
content_type: str # "answers" or "articles"
content_id: int
comments: List[CommentData] = Field(default_factory=list)
total_count: int = Field(default=0, description="总评论数(可能超过实际返回)")
```
同时更新 CrawlResult 以支持包含评论:
```python
class CrawlResult(BaseModel):
"""爬取结果"""
success: bool
content_type: str # "answer" or "article"
data: Optional[AnswerData | ArticleData] = None
comments: Optional[List[CommentData]] = None # 新增
error: Optional[str] = None
```
</action>
<verify>
<automated>python -c "from kb_workflow.crawler.models import CommentData, CommentsResult; print('OK')"</automated>
</verify>
<done>
- CommentData 模型定义完成
- CommentsResult 模型定义完成
- CrawlResult 支持 comments 字段
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: 实现 crawl_comments 方法</name>
<files>kb_workflow/crawler/zhihu_crawler.py, tests/test_crawler/test_comment_crawler.py</files>
<behavior>
- Test 1: crawl_comments("answers", 12345) 返回 CommentsResult
- Test 2: 评论列表包含热门评论(order=hot)
- Test 3: 每条评论包含 content, author, like_count
- Test 4: 不展开嵌套回复(只有第一层评论)
- Test 5: crawl_with_comments=True 参数在 crawl_answer 中触发评论爬取
</behavior>
<action>
在 ZhihuCrawler 类中实现评论爬取功能。
**1. 在 ZhihuCrawler 类中添加方法:**
```python
# 在 kb_workflow/crawler/zhihu_crawler.py 中添加
from .models import CommentData, CommentsResult, CommentAuthor
class ZhihuCrawler:
# ... 现有代码 ...
COMMENT_API_TEMPLATE = (
"https://www.zhihu.com/api/v4/comment_v5/"
"{content_type}/{content_id}/comments"
"?order=hot&limit=20"
)
def crawl_comments(
self,
content_type: str, # "answers" or "articles"
content_id: int,
include_comments: bool = True
) -> CommentsResult:
"""爬取热门评论
Args:
content_type: 内容类型 ("answers" 或 "articles")
content_id: 内容 ID
include_comments: 是否包含评论(保留参数兼容性)
Returns:
CommentsResult 包含评论列表
"""
if not include_comments:
return CommentsResult(
content_type=content_type,
content_id=content_id,
comments=[],
total_count=0
)
try:
self.browser = PlaywrightBrowser(headless=self.headless, timeout=self.timeout)
self.browser.start(cookie=self._get_cookie())
# 构建评论 API URL
api_url = self.COMMENT_API_TEMPLATE.format(
content_type=content_type,
content_id=content_id
)
# 使用 Playwright 的 request API 直接调用
response = self.browser.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)
# 获取总数
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 as e:
return CommentsResult(
content_type=content_type,
content_id=content_id,
comments=[],
total_count=0
)
finally:
if self.browser:
self.browser.close()
def crawl_answer(self, url: str, include_comments: bool = False) -> CrawlResult:
"""爬取知乎回答(支持可选评论)
Args:
url: 回答 URL
include_comments: 是否包含热门评论
Returns:
CrawlResult 包含回答数据和可选评论
"""
# 原有逻辑...
result = self._crawl_answer_original(url)
if result.success and include_comments:
# 爬取评论
comments_result = self.crawl_comments(
content_type="answers",
content_id=result.data.id,
include_comments=True
)
result.comments = comments_result.comments
return result
```
**2. 创建 tests/test_crawler/test_comment_crawler.py:**
```python
import pytest
from unittest.mock import Mock, patch, MagicMock
from kb_workflow.crawler.zhihu_crawler import ZhihuCrawler
from kb_workflow.crawler.models import CommentData, CommentsResult
class TestCommentCrawler:
@pytest.fixture
def crawler(self):
return ZhihuCrawler(headless=True)
@pytest.fixture
def mock_comment_response(self):
return {
"data": [
{
"id": 111,
"content": "这是一条热门评论",
"author": {"name": "用户A", "url_token": "user-a"},
"created_time": 1234567890,
"like_count": 100,
"child_comment_count": 5,
"is_author": False
},
{
"id": 222,
"content": "作者回复",
"author": {"name": "作者", "url_token": "author"},
"created_time": 1234567900,
"like_count": 50,
"child_comment_count": 0,
"is_author": True
}
],
"paging": {"totals": 100}
}
def test_crawl_comments_returns_comments_result(self, crawler, mock_comment_response):
"""crawl_comments 返回 CommentsResult"""
with patch.object(crawler, '_get_cookie', return_value="test_cookie"):
with patch('kb_workflow.crawler.zhihu_crawler.PlaywrightBrowser') as MockBrowser:
mock_browser = MockBrowser.return_value
mock_response = MagicMock()
mock_response.status = 200
mock_response.json.return_value = mock_comment_response
mock_browser.page.request.get.return_value = mock_response
result = crawler.crawl_comments("answers", 12345)
assert isinstance(result, CommentsResult)
assert result.content_type == "answers"
assert result.content_id == 12345
def test_comments_are_hot_order(self, crawler, mock_comment_response):
"""评论使用热门排序"""
with patch.object(crawler, '_get_cookie', return_value="test_cookie"):
with patch('kb_workflow.crawler.zhihu_crawler.PlaywrightBrowser') as MockBrowser:
mock_browser = MockBrowser.return_value
mock_response = MagicMock()
mock_response.status = 200
mock_response.json.return_value = mock_comment_response
mock_browser.page.request.get.return_value = mock_response
crawler.crawl_comments("answers", 12345)
# 验证 API URL 包含 order=hot
call_args = mock_browser.page.request.get.call_args
assert "order=hot" in call_args[0][0]
def test_comment_has_required_fields(self, crawler, mock_comment_response):
"""每条评论包含必要字段"""
with patch.object(crawler, '_get_cookie', return_value="test_cookie"):
with patch('kb_workflow.crawler.zhihu_crawler.PlaywrightBrowser') as MockBrowser:
mock_browser = MockBrowser.return_value
mock_response = MagicMock()
mock_response.status = 200
mock_response.json.return_value = mock_comment_response
mock_browser.page.request.get.return_value = mock_response
result = crawler.crawl_comments("answers", 12345)
assert len(result.comments) == 2
comment = result.comments[0]
assert comment.content == "这是一条热门评论"
assert comment.author.name == "用户A"
assert comment.like_count == 100
def test_no_nested_replies(self, crawler, mock_comment_response):
"""不展开嵌套回复"""
with patch.object(crawler, '_get_cookie', return_value="test_cookie"):
with patch('kb_workflow.crawler.zhihu_crawler.PlaywrightBrowser') as MockBrowser:
mock_browser = MockBrowser.return_value
mock_response = MagicMock()
mock_response.status = 200
mock_response.json.return_value = mock_comment_response
mock_browser.page.request.get.return_value = mock_response
result = crawler.crawl_comments("answers", 12345)
# 只返回第一层评论,不包含子评论
assert len(result.comments) == 2
# child_count 记录子评论数但不展开
assert result.comments[0].child_count == 5
def test_include_comments_parameter(self, crawler):
"""include_comments=False 不爬取评论"""
result = crawler.crawl_comments("answers", 12345, include_comments=False)
assert result.comments == []
assert result.total_count == 0
```
</action>
<verify>
<automated>pytest tests/test_crawler/test_comment_crawler.py -x --tb=short</automated>
</verify>
<done>
- crawl_comments() 方法返回 CommentsResult
- 评论使用 order=hot 排序
- 只返回第一层评论
- include_comments 参数控制是否爬取
- 所有测试通过
</done>
</task>
</tasks>
<verification>
**自动化验证:**
```bash
pytest tests/test_crawler/test_comment_crawler.py -x -v
```
**集成验证:**
```python
from kb_workflow.crawler import ZhihuCrawler
crawler = ZhihuCrawler()
result = crawler.crawl_answer("https://www.zhihu.com/question/123/answer/456", include_comments=True)
assert result.comments is not None
```
</verification>
<success_criteria>
1. CommentData 模型定义完成
2. crawl_comments() 方法实现完成
3. 评论使用热门排序 (order=hot)
4. 只爬取第一层评论,不展开嵌套
5. include_comments 参数控制评论爬取
6. 所有测试通过
</success_criteria>
<output>
After completion, create `.planning/phases/08-crawler/08-02-SUMMARY.md`
</output>