docs(08): create phase 8 plans for crawler implementation
This commit is contained in:
+10
-10
@@ -90,12 +90,12 @@ Plans:
|
|||||||
2. 扫码失败时用户可以手动设置 Cookie 进行认证
|
2. 扫码失败时用户可以手动设置 Cookie 进行认证
|
||||||
3. Cookie 自动保存到本地文件,下次运行自动复用
|
3. Cookie 自动保存到本地文件,下次运行自动复用
|
||||||
4. Cookie 过期时系统自动检测并提示用户重新登录
|
4. Cookie 过期时系统自动检测并提示用户重新登录
|
||||||
**Plans**: 3 plans
|
**Plans**: 3 plans (all complete)
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] 07-01-PLAN.md — 二维码扫码登录核心逻辑 (AUTH-01) + Wave 0 测试脚手架
|
- [x] 07-01-PLAN.md — 二维码扫码登录核心逻辑 (AUTH-01) + Wave 0 测试脚手架
|
||||||
- [ ] 07-02-PLAN.md — Cookie 导入和持久化管理 (AUTH-02, AUTH-03)
|
- [x] 07-02-PLAN.md — Cookie 导入和持久化管理 (AUTH-02, AUTH-03)
|
||||||
- [ ] 07-03-PLAN.md — Cookie 验证 + CLI login 子命令 (AUTH-04)
|
- [x] 07-03-PLAN.md — Cookie 验证 + CLI login 子命令 (AUTH-04)
|
||||||
|
|
||||||
#### Phase 8: 内容爬取实现
|
#### Phase 8: 内容爬取实现
|
||||||
**Goal**: 实现知乎问答、专栏/文章、评论的完整爬取功能,包含网络反爬措施
|
**Goal**: 实现知乎问答、专栏/文章、评论的完整爬取功能,包含网络反爬措施
|
||||||
@@ -107,12 +107,12 @@ Plans:
|
|||||||
3. 用户可以选择是否爬取内容下的评论
|
3. 用户可以选择是否爬取内容下的评论
|
||||||
4. 爬取结果包含作者信息、发布时间、点赞数等完整元数据
|
4. 爬取结果包含作者信息、发布时间、点赞数等完整元数据
|
||||||
5. 用户可以配置代理 IP 以降低被封风险
|
5. 用户可以配置代理 IP 以降低被封风险
|
||||||
**Plans**: TBD
|
**Plans**: 3 plans in 2 waves
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] 08-01: 问答和专栏内容爬取 (CONT-01, CONT-02, CONT-04)
|
- [ ] 08-01-PLAN.md — 问答和专栏内容爬取 (CONT-01, CONT-02, CONT-04) — Wave 1
|
||||||
- [ ] 08-02: 评论爬取功能 (CONT-03)
|
- [ ] 08-02-PLAN.md — 评论爬取功能 (CONT-03) — Wave 1 (parallel)
|
||||||
- [ ] 08-03: 网络反爬措施 (NET-01, NET-02, NET-03)
|
- [ ] 08-03-PLAN.md — 网络反爬措施 (NET-01, NET-02, NET-03) — Wave 2
|
||||||
|
|
||||||
#### Phase 9: Pipeline 整合
|
#### Phase 9: Pipeline 整合
|
||||||
**Goal**: 将新爬虫实现整合进现有 Pipeline 架构,替换原有知乎处理器
|
**Goal**: 将新爬虫实现整合进现有 Pipeline 架构,替换原有知乎处理器
|
||||||
@@ -140,6 +140,6 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
|
|||||||
| 4. Pipeline | v1.0 | 2/2 | Complete | 2026-03-15 |
|
| 4. Pipeline | v1.0 | 2/2 | Complete | 2026-03-15 |
|
||||||
| 5. 归档与目录优化 | v1.1 | 3/3 | Complete | 2026-03-16 |
|
| 5. 归档与目录优化 | v1.1 | 3/3 | Complete | 2026-03-16 |
|
||||||
| 6. 代码整合 | v1.1 | 3/3 | Complete | 2026-03-16 |
|
| 6. 代码整合 | v1.1 | 3/3 | Complete | 2026-03-16 |
|
||||||
| 7. 登录认证体系 | v1.2 | 0/3 | Ready to execute | - |
|
| 7. 登录认证体系 | v1.2 | 3/3 | Complete | 2026-03-17 |
|
||||||
| 8. 内容爬取实现 | v1.2 | 0/3 | Not started | - |
|
| 8. 内容爬取实现 | v1.2 | 0/3 | Ready to execute | - |
|
||||||
| 9. Pipeline 整合 | v1.2 | 0/1 | Not started | - |
|
| 9. Pipeline 整合 | v1.2 | 0/1 | Not started | - |
|
||||||
|
|||||||
@@ -0,0 +1,429 @@
|
|||||||
|
---
|
||||||
|
phase: 08-crawler
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- kb_workflow/crawler/__init__.py
|
||||||
|
- kb_workflow/crawler/models.py
|
||||||
|
- kb_workflow/crawler/zhihu_crawler.py
|
||||||
|
- tests/test_crawler/__init__.py
|
||||||
|
- tests/test_crawler/conftest.py
|
||||||
|
- tests/test_crawler/test_answer_crawler.py
|
||||||
|
- tests/test_crawler/test_article_crawler.py
|
||||||
|
- tests/test_crawler/test_metadata_extraction.py
|
||||||
|
autonomous: true
|
||||||
|
requirements: [CONT-01, CONT-02, CONT-04]
|
||||||
|
user_setup: []
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "用户可以爬取知乎问答,获取问题标题、描述、回答内容"
|
||||||
|
- "用户可以爬取知乎专栏/文章,获取标题和正文"
|
||||||
|
- "爬取结果包含作者信息、发布时间、点赞数等元数据"
|
||||||
|
artifacts:
|
||||||
|
- path: "kb_workflow/crawler/zhihu_crawler.py"
|
||||||
|
provides: "ZhihuCrawler 类,实现 API 拦截和内容提取"
|
||||||
|
min_lines: 100
|
||||||
|
- path: "kb_workflow/crawler/models.py"
|
||||||
|
provides: "Pydantic 数据模型定义"
|
||||||
|
contains: "class.*AnswerData"
|
||||||
|
- path: "tests/test_crawler/test_answer_crawler.py"
|
||||||
|
provides: "问答爬取测试"
|
||||||
|
exports: ["test_crawl_answer"]
|
||||||
|
- path: "tests/test_crawler/test_article_crawler.py"
|
||||||
|
provides: "专栏爬取测试"
|
||||||
|
exports: ["test_crawl_article"]
|
||||||
|
key_links:
|
||||||
|
- from: "kb_workflow/crawler/zhihu_crawler.py"
|
||||||
|
to: "kb_workflow/auth/zhihu_login.py"
|
||||||
|
via: "Cookie 加载"
|
||||||
|
pattern: "from kb_workflow.auth"
|
||||||
|
- from: "kb_workflow/crawler/zhihu_crawler.py"
|
||||||
|
to: "Playwright page.route"
|
||||||
|
via: "API 拦截"
|
||||||
|
pattern: "page\\.route"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
实现知乎问答和专栏/文章的内容爬取功能,使用 Playwright 拦截 API 请求获取结构化 JSON 数据。
|
||||||
|
|
||||||
|
Purpose: 为 Pipeline 提供可靠的知乎内容爬取能力,复用 Phase 7 的 Cookie 管理
|
||||||
|
Output: ZhihuCrawler 类 + 数据模型 + 测试
|
||||||
|
</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/07-login-auth/07-03-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types from existing codebase -->
|
||||||
|
|
||||||
|
From kb_workflow/auth/zhihu_login.py:
|
||||||
|
```python
|
||||||
|
class ZhihuLoginResult(BaseModel):
|
||||||
|
success: bool
|
||||||
|
cookie_str: str
|
||||||
|
qrcode_path: Optional[Path]
|
||||||
|
error_message: Optional[str]
|
||||||
|
|
||||||
|
class CookieValidationResult(BaseModel):
|
||||||
|
valid: bool
|
||||||
|
username: Optional[str]
|
||||||
|
user_id: Optional[str]
|
||||||
|
reason: Optional[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
From kb_workflow/metadata/zhihu_extractor.py:
|
||||||
|
```python
|
||||||
|
class PlaywrightBrowser:
|
||||||
|
def start(self, cookie: str = None)
|
||||||
|
def goto(self, url: str, wait_until: str = "networkidle")
|
||||||
|
def close(self)
|
||||||
|
```
|
||||||
|
|
||||||
|
From kb_workflow/pipeline/config.py:
|
||||||
|
```python
|
||||||
|
class KBSettings(BaseSettings):
|
||||||
|
zhihu_cookie: SecretStr
|
||||||
|
intermediate_path: str
|
||||||
|
```
|
||||||
|
|
||||||
|
API 拦截目标 (from RESEARCH.md):
|
||||||
|
- 问答: `/api/v4/answers/{id}` → JSON 响应
|
||||||
|
- 专栏: zhuanlan 页面 API 或 DOM 解析
|
||||||
|
- 评论: `/api/v4/comment_v5/{type}/{id}/comments?order=hot`
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: 创建数据模型和测试脚手架</name>
|
||||||
|
<files>kb_workflow/crawler/__init__.py, kb_workflow/crawler/models.py, tests/test_crawler/__init__.py, tests/test_crawler/conftest.py</files>
|
||||||
|
<action>
|
||||||
|
创建 ZhihuCrawler 模块的数据模型和测试基础设施。
|
||||||
|
|
||||||
|
**1. 创建 kb_workflow/crawler/models.py:**
|
||||||
|
```python
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
class AuthorInfo(BaseModel):
|
||||||
|
"""作者信息"""
|
||||||
|
name: str = Field(..., description="作者名称")
|
||||||
|
url_token: Optional[str] = Field(None, description="作者 URL token")
|
||||||
|
headline: Optional[str] = Field(None, description="作者签名")
|
||||||
|
|
||||||
|
class ContentMetadata(BaseModel):
|
||||||
|
"""内容元数据"""
|
||||||
|
author: Optional[AuthorInfo] = None
|
||||||
|
voteup_count: int = Field(default=0, description="点赞数")
|
||||||
|
comment_count: int = Field(default=0, description="评论数")
|
||||||
|
created_time: Optional[int] = Field(None, description="创建时间戳")
|
||||||
|
updated_time: Optional[int] = Field(None, description="更新时间戳")
|
||||||
|
|
||||||
|
class AnswerData(BaseModel):
|
||||||
|
"""知乎回答数据"""
|
||||||
|
id: int
|
||||||
|
question_id: int
|
||||||
|
question_title: str
|
||||||
|
content: str # HTML
|
||||||
|
excerpt: str # 纯文本摘要
|
||||||
|
metadata: ContentMetadata
|
||||||
|
|
||||||
|
class ArticleData(BaseModel):
|
||||||
|
"""知乎专栏文章数据"""
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
content: str # HTML
|
||||||
|
excerpt: str
|
||||||
|
metadata: ContentMetadata
|
||||||
|
|
||||||
|
class CrawlResult(BaseModel):
|
||||||
|
"""爬取结果"""
|
||||||
|
success: bool
|
||||||
|
content_type: str # "answer" or "article"
|
||||||
|
data: Optional[AnswerData | ArticleData] = None
|
||||||
|
error: Optional[str] = None
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 创建 tests/test_crawler/conftest.py:**
|
||||||
|
- mock_page: Mock Playwright page 对象
|
||||||
|
- mock_browser: Mock PlaywrightBrowser 实例
|
||||||
|
- sample_answer_api_response: 示例问答 API 响应
|
||||||
|
- sample_article_api_response: 示例专栏 API 响应
|
||||||
|
|
||||||
|
**3. 创建 kb_workflow/crawler/__init__.py:** 导出主要类
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pytest tests/test_crawler/ -x --tb=short -k "conftest or init"</automated>
|
||||||
|
<manual>Verify models.py can be imported: python -c "from kb_workflow.crawler.models import AnswerData; print('OK')"</manual>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- models.py 包含 AnswerData, ArticleData, ContentMetadata, AuthorInfo 类
|
||||||
|
- conftest.py 包含 mock_page, mock_browser fixtures
|
||||||
|
- 所有模块可正常导入
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: 实现 ZhihuCrawler 核心类</name>
|
||||||
|
<files>kb_workflow/crawler/zhihu_crawler.py, tests/test_crawler/test_answer_crawler.py, tests/test_crawler/test_article_crawler.py</files>
|
||||||
|
<behavior>
|
||||||
|
- Test 1: crawl_answer() 返回 AnswerData,包含问题标题、回答内容、元数据
|
||||||
|
- Test 2: crawl_article() 返回 ArticleData,包含标题、正文、元数据
|
||||||
|
- Test 3: 无效 URL 返回 CrawlResult(success=False, error=...)
|
||||||
|
- Test 4: API 拦截正确捕获 /api/v4/answers/ 响应
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
实现 ZhihuCrawler 类,使用 Playwright route 拦截 API 请求。
|
||||||
|
|
||||||
|
**1. 创建 kb_workflow/crawler/zhihu_crawler.py:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from playwright.sync_api import Route, Response
|
||||||
|
from kb_workflow.metadata.zhihu_extractor import PlaywrightBrowser
|
||||||
|
from kb_workflow.pipeline.config import get_settings
|
||||||
|
from .models import AnswerData, ArticleData, ContentMetadata, AuthorInfo, CrawlResult
|
||||||
|
|
||||||
|
class ZhihuApiInterceptor:
|
||||||
|
"""API 拦截器,捕获知乎 API 响应"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.captured_data: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
def handle_route(self, route: Route):
|
||||||
|
"""处理拦截的请求"""
|
||||||
|
url = route.request.url
|
||||||
|
|
||||||
|
# 拦截答案 API
|
||||||
|
if "/api/v4/answers/" in url:
|
||||||
|
response = route.fetch()
|
||||||
|
self.captured_data["answer"] = response.json()
|
||||||
|
route.fulfill(response=response)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 拦截文章 API (专栏)
|
||||||
|
if "/api/v4/articles/" in url or "zhuanlan" in url:
|
||||||
|
response = route.fetch()
|
||||||
|
self.captured_data["article"] = response.json()
|
||||||
|
route.fulfill(response=response)
|
||||||
|
return
|
||||||
|
|
||||||
|
route.continue_()
|
||||||
|
|
||||||
|
class ZhihuCrawler:
|
||||||
|
"""知乎内容爬虫"""
|
||||||
|
|
||||||
|
ANSWER_PATTERN = r"zhihu\.com/question/(\d+)/answer/(\d+)"
|
||||||
|
ARTICLE_PATTERN = r"zhuanlan\.zhihu\.com/p/(\d+)"
|
||||||
|
|
||||||
|
def __init__(self, headless: bool = True, timeout: int = 30):
|
||||||
|
self.headless = headless
|
||||||
|
self.timeout = timeout
|
||||||
|
self.browser: Optional[PlaywrightBrowser] = None
|
||||||
|
self.interceptor: Optional[ZhihuApiInterceptor] = None
|
||||||
|
|
||||||
|
def _get_cookie(self) -> str:
|
||||||
|
"""获取 Cookie"""
|
||||||
|
settings = get_settings()
|
||||||
|
return settings.zhihu_cookie.get_secret_value()
|
||||||
|
|
||||||
|
def _parse_author(self, data: Dict) -> Optional[AuthorInfo]:
|
||||||
|
"""解析作者信息"""
|
||||||
|
author = data.get("author", {})
|
||||||
|
if not author:
|
||||||
|
return None
|
||||||
|
return AuthorInfo(
|
||||||
|
name=author.get("name", ""),
|
||||||
|
url_token=author.get("url_token"),
|
||||||
|
headline=author.get("headline")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_metadata(self, data: Dict) -> ContentMetadata:
|
||||||
|
"""解析元数据"""
|
||||||
|
return ContentMetadata(
|
||||||
|
author=self._parse_author(data),
|
||||||
|
voteup_count=data.get("voteup_count", 0),
|
||||||
|
comment_count=data.get("comment_count", 0),
|
||||||
|
created_time=data.get("created_time"),
|
||||||
|
updated_time=data.get("updated_time")
|
||||||
|
)
|
||||||
|
|
||||||
|
def crawl_answer(self, url: str) -> CrawlResult:
|
||||||
|
"""爬取知乎回答"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
match = re.search(self.ANSWER_PATTERN, url)
|
||||||
|
if not match:
|
||||||
|
return CrawlResult(success=False, error="Invalid answer URL")
|
||||||
|
|
||||||
|
question_id, answer_id = match.groups()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.browser = PlaywrightBrowser(headless=self.headless, timeout=self.timeout)
|
||||||
|
self.browser.start(cookie=self._get_cookie())
|
||||||
|
self.interceptor = ZhihuApiInterceptor()
|
||||||
|
|
||||||
|
# 设置路由拦截
|
||||||
|
self.browser.page.route("**/api/v4/**", self.interceptor.handle_route)
|
||||||
|
|
||||||
|
# 访问页面触发 API 请求
|
||||||
|
self.browser.goto(url, wait_until="networkidle")
|
||||||
|
|
||||||
|
# 等待数据捕获
|
||||||
|
import time
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
if "answer" not in self.interceptor.captured_data:
|
||||||
|
return CrawlResult(success=False, error="Failed to capture answer data")
|
||||||
|
|
||||||
|
answer_data = self.interceptor.captured_data["answer"]
|
||||||
|
|
||||||
|
# 解析数据
|
||||||
|
result_data = AnswerData(
|
||||||
|
id=int(answer_id),
|
||||||
|
question_id=int(question_id),
|
||||||
|
question_title=answer_data.get("question", {}).get("title", ""),
|
||||||
|
content=answer_data.get("content", ""),
|
||||||
|
excerpt=answer_data.get("excerpt", ""),
|
||||||
|
metadata=self._parse_metadata(answer_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
return CrawlResult(success=True, content_type="answer", data=result_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CrawlResult(success=False, error=str(e))
|
||||||
|
finally:
|
||||||
|
if self.browser:
|
||||||
|
self.browser.close()
|
||||||
|
|
||||||
|
def crawl_article(self, url: str) -> CrawlResult:
|
||||||
|
"""爬取知乎专栏文章"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
match = re.search(self.ARTICLE_PATTERN, url)
|
||||||
|
if not match:
|
||||||
|
return CrawlResult(success=False, error="Invalid article URL")
|
||||||
|
|
||||||
|
article_id = match.groups()[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.browser = PlaywrightBrowser(headless=self.headless, timeout=self.timeout)
|
||||||
|
self.browser.start(cookie=self._get_cookie())
|
||||||
|
self.interceptor = ZhihuApiInterceptor()
|
||||||
|
|
||||||
|
self.browser.page.route("**/api/v4/**", self.interceptor.handle_route)
|
||||||
|
|
||||||
|
self.browser.goto(url, wait_until="networkidle")
|
||||||
|
|
||||||
|
import time
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
if "article" not in self.interceptor.captured_data:
|
||||||
|
# 备选方案:DOM 解析
|
||||||
|
return self._crawl_article_from_dom(url, article_id)
|
||||||
|
|
||||||
|
article_data = self.interceptor.captured_data["article"]
|
||||||
|
|
||||||
|
result_data = ArticleData(
|
||||||
|
id=int(article_id),
|
||||||
|
title=article_data.get("title", ""),
|
||||||
|
content=article_data.get("content", ""),
|
||||||
|
excerpt=article_data.get("excerpt", ""),
|
||||||
|
metadata=self._parse_metadata(article_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
return CrawlResult(success=True, content_type="article", data=result_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CrawlResult(success=False, error=str(e))
|
||||||
|
finally:
|
||||||
|
if self.browser:
|
||||||
|
self.browser.close()
|
||||||
|
|
||||||
|
def _crawl_article_from_dom(self, url: str, article_id: str) -> CrawlResult:
|
||||||
|
"""从 DOM 解析专栏文章(备选方案)"""
|
||||||
|
# 复用现有的 ZhihuPlaywrightExtractor 逻辑
|
||||||
|
from kb_workflow.metadata.zhihu_extractor import ZhihuPlaywrightExtractor
|
||||||
|
|
||||||
|
extractor = ZhihuPlaywrightExtractor(
|
||||||
|
headless=self.headless,
|
||||||
|
timeout=self.timeout,
|
||||||
|
cookie=self._get_cookie()
|
||||||
|
)
|
||||||
|
result = extractor.extract(url)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
return CrawlResult(success=False, error="Failed to extract article from DOM")
|
||||||
|
|
||||||
|
result_data = ArticleData(
|
||||||
|
id=int(article_id),
|
||||||
|
title=result.get("title", ""),
|
||||||
|
content=result.get("content", ""),
|
||||||
|
excerpt=result.get("content_markdown", "")[:200], # 摘要
|
||||||
|
metadata=ContentMetadata(
|
||||||
|
author=AuthorInfo(name=result.get("author", "")),
|
||||||
|
voteup_count=result.get("vote_up", 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return CrawlResult(success=True, content_type="article", data=result_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 创建测试文件:**
|
||||||
|
- tests/test_crawler/test_answer_crawler.py: 测试 crawl_answer()
|
||||||
|
- tests/test_crawler/test_article_crawler.py: 测试 crawl_article()
|
||||||
|
- tests/test_crawler/test_metadata_extraction.py: 测试元数据提取
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pytest tests/test_crawler/test_answer_crawler.py tests/test_crawler/test_article_crawler.py tests/test_crawler/test_metadata_extraction.py -x --tb=short</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- ZhihuCrawler.crawl_answer() 返回 CrawlResult 包含 AnswerData
|
||||||
|
- ZhihuCrawler.crawl_article() 返回 CrawlResult 包含 ArticleData
|
||||||
|
- 元数据正确提取作者、点赞数、评论数
|
||||||
|
- 所有测试通过
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
**Wave 0 检查:**
|
||||||
|
- [ ] tests/test_crawler/__init__.py 存在
|
||||||
|
- [ ] tests/test_crawler/conftest.py 包含 fixtures
|
||||||
|
- [ ] tests/test_crawler/test_answer_crawler.py 测试文件存在
|
||||||
|
- [ ] tests/test_crawler/test_article_crawler.py 测试文件存在
|
||||||
|
- [ ] tests/test_crawler/test_metadata_extraction.py 测试文件存在
|
||||||
|
|
||||||
|
**自动化验证:**
|
||||||
|
```bash
|
||||||
|
pytest tests/test_crawler/ -x -v
|
||||||
|
```
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
1. ZhihuCrawler 类可正常导入
|
||||||
|
2. crawl_answer() 能爬取知乎问答并返回结构化数据
|
||||||
|
3. crawl_article() 能爬取知乎专栏并返回结构化数据
|
||||||
|
4. 数据包含作者、点赞数、评论数等完整元数据
|
||||||
|
5. 所有测试通过
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/08-crawler/08-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
---
|
||||||
|
phase: 08-crawler
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
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
|
||||||
|
|
||||||
|
<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": {...}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</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>
|
||||||
@@ -0,0 +1,710 @@
|
|||||||
|
---
|
||||||
|
phase: 08-crawler
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: [08-01]
|
||||||
|
files_modified:
|
||||||
|
- kb_workflow/crawler/config.py
|
||||||
|
- kb_workflow/crawler/zhihu_crawler.py
|
||||||
|
- tests/test_crawler/test_proxy_config.py
|
||||||
|
- tests/test_crawler/test_rate_limiter.py
|
||||||
|
- tests/test_crawler/test_api_intercept.py
|
||||||
|
autonomous: true
|
||||||
|
requirements: [NET-01, NET-02, NET-03]
|
||||||
|
user_setup:
|
||||||
|
- service: proxy
|
||||||
|
why: "防止 IP 被知乎封禁"
|
||||||
|
env_vars:
|
||||||
|
- name: HTTP_PROXY
|
||||||
|
source: "代理服务地址,如 http://127.0.0.1:7890"
|
||||||
|
- name: HTTPS_PROXY
|
||||||
|
source: "代理服务地址,如 http://127.0.0.1:7890"
|
||||||
|
dashboard_config: []
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "用户可以通过环境变量配置代理 IP"
|
||||||
|
- "不配置代理时,爬虫拒绝运行并提示错误"
|
||||||
|
- "每次请求间有随机延迟(1-3秒)"
|
||||||
|
- "请求失败时有限重试(3次),间隔递增"
|
||||||
|
- "x-zse-96 签名由浏览器自动处理,无需手动逆向"
|
||||||
|
artifacts:
|
||||||
|
- path: "kb_workflow/crawler/config.py"
|
||||||
|
provides: "CrawlerConfig 配置类,代理和延迟设置"
|
||||||
|
contains: "class CrawlerConfig"
|
||||||
|
- path: "kb_workflow/crawler/zhihu_crawler.py"
|
||||||
|
provides: "代理应用和重试逻辑"
|
||||||
|
contains: "def _get_proxy_config"
|
||||||
|
- path: "tests/test_crawler/test_proxy_config.py"
|
||||||
|
provides: "代理配置测试"
|
||||||
|
exports: ["test_proxy_required"]
|
||||||
|
- path: "tests/test_crawler/test_rate_limiter.py"
|
||||||
|
provides: "频率控制测试"
|
||||||
|
exports: ["test_random_delay"]
|
||||||
|
key_links:
|
||||||
|
- from: "CrawlerConfig"
|
||||||
|
to: "HTTP_PROXY/HTTPS_PROXY"
|
||||||
|
via: "环境变量读取"
|
||||||
|
pattern: "os\\.environ\\.get.*PROXY"
|
||||||
|
- from: "ZhihuCrawler"
|
||||||
|
to: "browser.new_context(proxy=...)"
|
||||||
|
via: "Playwright 代理配置"
|
||||||
|
pattern: "new_context.*proxy"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
实现网络反爬措施:强制代理配置、请求频率控制、有限重试机制,以及利用浏览器自动处理 x-zse-96 签名。
|
||||||
|
|
||||||
|
Purpose: 降低被封风险,提高爬取稳定性
|
||||||
|
Output: CrawlerConfig + 代理/延迟/重试逻辑 + 测试
|
||||||
|
</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-PLAN.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- 从 08-01 创建的接口 -->
|
||||||
|
|
||||||
|
From kb_workflow/crawler/zhihu_crawler.py (Plan 01):
|
||||||
|
```python
|
||||||
|
class ZhihuCrawler:
|
||||||
|
def __init__(self, headless: bool = True, timeout: int = 30)
|
||||||
|
def _get_cookie(self) -> str
|
||||||
|
def crawl_answer(self, url: str) -> CrawlResult
|
||||||
|
def crawl_article(self, url: str) -> CrawlResult
|
||||||
|
```
|
||||||
|
|
||||||
|
代理配置模式 (from RESEARCH.md):
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
def get_proxy_config():
|
||||||
|
proxy_url = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
|
||||||
|
if not proxy_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
parsed = urlparse(proxy_url)
|
||||||
|
config = {"server": f"{parsed.scheme}://{parsed.hostname}:{parsed.port}"}
|
||||||
|
|
||||||
|
if parsed.username and parsed.password:
|
||||||
|
config["username"] = parsed.username
|
||||||
|
config["password"] = parsed.password
|
||||||
|
|
||||||
|
return config
|
||||||
|
```
|
||||||
|
|
||||||
|
重试模式 (from RESEARCH.md):
|
||||||
|
```python
|
||||||
|
from tenacity import Retrying, stop_after_attempt, wait_exponential
|
||||||
|
|
||||||
|
for attempt in Retrying(
|
||||||
|
stop=stop_after_attempt(3),
|
||||||
|
wait=wait_exponential(multiplier=1, min=2, max=10),
|
||||||
|
reraise=True
|
||||||
|
):
|
||||||
|
with attempt:
|
||||||
|
# 爬取逻辑
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: 创建 CrawlerConfig 配置类</name>
|
||||||
|
<files>kb_workflow/crawler/config.py, tests/test_crawler/test_proxy_config.py</files>
|
||||||
|
<action>
|
||||||
|
创建爬虫配置类,处理代理和延迟设置。
|
||||||
|
|
||||||
|
**1. 创建 kb_workflow/crawler/config.py:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Crawler configuration
|
||||||
|
|
||||||
|
Handles proxy settings, rate limiting, and retry configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyConfig(BaseModel):
|
||||||
|
"""代理配置"""
|
||||||
|
server: str = Field(..., description="代理服务器地址")
|
||||||
|
username: Optional[str] = Field(None, description="代理用户名")
|
||||||
|
password: Optional[str] = Field(None, description="代理密码")
|
||||||
|
|
||||||
|
def to_playwright_format(self) -> Dict[str, Any]:
|
||||||
|
"""转换为 Playwright 代理格式"""
|
||||||
|
config = {"server": self.server}
|
||||||
|
if self.username:
|
||||||
|
config["username"] = self.username
|
||||||
|
if self.password:
|
||||||
|
config["password"] = self.password
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
class CrawlerConfig(BaseModel):
|
||||||
|
"""爬虫配置"""
|
||||||
|
|
||||||
|
# 代理设置
|
||||||
|
proxy: Optional[ProxyConfig] = Field(None, description="代理配置")
|
||||||
|
proxy_required: bool = Field(default=True, description="是否强制要求代理")
|
||||||
|
|
||||||
|
# 请求频率
|
||||||
|
min_delay: float = Field(default=1.0, description="最小延迟(秒)")
|
||||||
|
max_delay: float = Field(default=3.0, description="最大延迟(秒)")
|
||||||
|
|
||||||
|
# 重试设置
|
||||||
|
max_retries: int = Field(default=3, description="最大重试次数")
|
||||||
|
retry_min_wait: float = Field(default=2.0, description="重试最小等待(秒)")
|
||||||
|
retry_max_wait: float = Field(default=10.0, description="重试最大等待(秒)")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_env(cls) -> "CrawlerConfig":
|
||||||
|
"""从环境变量加载配置"""
|
||||||
|
proxy = cls._parse_proxy_env()
|
||||||
|
return cls(proxy=proxy)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_proxy_env() -> Optional[ProxyConfig]:
|
||||||
|
"""解析代理环境变量"""
|
||||||
|
proxy_url = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
|
||||||
|
|
||||||
|
if not proxy_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
parsed = urlparse(proxy_url)
|
||||||
|
server = f"{parsed.scheme}://{parsed.hostname}"
|
||||||
|
|
||||||
|
if parsed.port:
|
||||||
|
server += f":{parsed.port}"
|
||||||
|
|
||||||
|
return ProxyConfig(
|
||||||
|
server=server,
|
||||||
|
username=parsed.username,
|
||||||
|
password=parsed.password
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_proxy(self) -> None:
|
||||||
|
"""验证代理配置
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 如果 proxy_required=True 但未配置代理
|
||||||
|
"""
|
||||||
|
if self.proxy_required and not self.proxy:
|
||||||
|
raise ValueError(
|
||||||
|
"代理配置缺失!请设置环境变量 HTTP_PROXY 或 HTTPS_PROXY。\n"
|
||||||
|
"示例: export HTTPS_PROXY=http://127.0.0.1:7890"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_crawler_config() -> CrawlerConfig:
|
||||||
|
"""获取爬虫配置单例"""
|
||||||
|
return CrawlerConfig.from_env()
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 创建 tests/test_crawler/test_proxy_config.py:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from kb_workflow.crawler.config import (
|
||||||
|
CrawlerConfig,
|
||||||
|
ProxyConfig,
|
||||||
|
get_crawler_config
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestProxyConfig:
|
||||||
|
|
||||||
|
def test_parse_proxy_from_https_env(self):
|
||||||
|
"""从 HTTPS_PROXY 解析代理"""
|
||||||
|
with patch.dict(os.environ, {"HTTPS_PROXY": "http://127.0.0.1:7890"}):
|
||||||
|
config = CrawlerConfig.from_env()
|
||||||
|
assert config.proxy is not None
|
||||||
|
assert config.proxy.server == "http://127.0.0.1:7890"
|
||||||
|
|
||||||
|
def test_parse_proxy_from_http_env(self):
|
||||||
|
"""从 HTTP_PROXY 解析代理(HTTPS_PROXY 优先)"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
"HTTP_PROXY": "http://127.0.0.1:8080",
|
||||||
|
"HTTPS_PROXY": "http://127.0.0.1:7890"
|
||||||
|
}, clear=False):
|
||||||
|
config = CrawlerConfig.from_env()
|
||||||
|
assert config.proxy.server == "http://127.0.0.1:7890"
|
||||||
|
|
||||||
|
def test_parse_proxy_with_auth(self):
|
||||||
|
"""解析带认证的代理"""
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
"HTTPS_PROXY": "http://user:pass@proxy.example.com:8080"
|
||||||
|
}):
|
||||||
|
config = CrawlerConfig.from_env()
|
||||||
|
assert config.proxy.username == "user"
|
||||||
|
assert config.proxy.password == "pass"
|
||||||
|
|
||||||
|
def test_no_proxy_configured(self):
|
||||||
|
"""未配置代理"""
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
# 清除所有代理相关环境变量
|
||||||
|
os.environ.pop("HTTP_PROXY", None)
|
||||||
|
os.environ.pop("HTTPS_PROXY", None)
|
||||||
|
config = CrawlerConfig.from_env()
|
||||||
|
assert config.proxy is None
|
||||||
|
|
||||||
|
def test_proxy_required_validation_fails(self):
|
||||||
|
"""强制代理但未配置时抛出异常"""
|
||||||
|
config = CrawlerConfig(proxy=None, proxy_required=True)
|
||||||
|
with pytest.raises(ValueError, match="代理配置缺失"):
|
||||||
|
config.validate_proxy()
|
||||||
|
|
||||||
|
def test_proxy_required_validation_passes(self):
|
||||||
|
"""配置代理后验证通过"""
|
||||||
|
config = CrawlerConfig(
|
||||||
|
proxy=ProxyConfig(server="http://127.0.0.1:7890"),
|
||||||
|
proxy_required=True
|
||||||
|
)
|
||||||
|
config.validate_proxy() # 不应抛出异常
|
||||||
|
|
||||||
|
def test_proxy_not_required(self):
|
||||||
|
"""不强制代理时验证通过"""
|
||||||
|
config = CrawlerConfig(proxy=None, proxy_required=False)
|
||||||
|
config.validate_proxy() # 不应抛出异常
|
||||||
|
|
||||||
|
def test_to_playwright_format(self):
|
||||||
|
"""转换为 Playwright 格式"""
|
||||||
|
proxy = ProxyConfig(
|
||||||
|
server="http://127.0.0.1:7890",
|
||||||
|
username="user",
|
||||||
|
password="pass"
|
||||||
|
)
|
||||||
|
result = proxy.to_playwright_format()
|
||||||
|
assert result == {
|
||||||
|
"server": "http://127.0.0.1:7890",
|
||||||
|
"username": "user",
|
||||||
|
"password": "pass"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pytest tests/test_crawler/test_proxy_config.py -x --tb=short</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- CrawlerConfig 类定义完成
|
||||||
|
- 从 HTTP_PROXY/HTTPS_PROXY 读取代理
|
||||||
|
- proxy_required=True 时未配置代理抛出异常
|
||||||
|
- 所有测试通过
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: 整合代理和延迟到 ZhihuCrawler</name>
|
||||||
|
<files>kb_workflow/crawler/zhihu_crawler.py, tests/test_crawler/test_rate_limiter.py, tests/test_crawler/test_api_intercept.py</files>
|
||||||
|
<behavior>
|
||||||
|
- Test 1: 未配置代理且 proxy_required=True 时抛出异常
|
||||||
|
- Test 2: 请求间有随机延迟 (1-3 秒)
|
||||||
|
- Test 3: 失败请求重试 3 次,间隔递增
|
||||||
|
- Test 4: API 拦截正确处理 x-zse-96 签名(由浏览器自动处理)
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
更新 ZhihuCrawler 类,整合代理配置、随机延迟和重试机制。
|
||||||
|
|
||||||
|
**1. 更新 kb_workflow/crawler/zhihu_crawler.py:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from playwright.sync_api import Route, Response
|
||||||
|
from tenacity import Retrying, stop_after_attempt, wait_exponential, RetryError
|
||||||
|
|
||||||
|
from kb_workflow.metadata.zhihu_extractor import PlaywrightBrowser
|
||||||
|
from kb_workflow.pipeline.config import get_settings
|
||||||
|
from .models import AnswerData, ArticleData, ContentMetadata, AuthorInfo, CrawlResult
|
||||||
|
from .config import CrawlerConfig, get_crawler_config
|
||||||
|
|
||||||
|
|
||||||
|
class ZhihuCrawler:
|
||||||
|
"""知乎内容爬虫
|
||||||
|
|
||||||
|
支持:
|
||||||
|
- 强制代理配置
|
||||||
|
- 随机请求延迟
|
||||||
|
- 有限重试
|
||||||
|
- 自动处理 x-zse-96 签名
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ... URL patterns 等现有代码 ...
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
headless: bool = True,
|
||||||
|
timeout: int = 30,
|
||||||
|
config: Optional[CrawlerConfig] = None
|
||||||
|
):
|
||||||
|
self.headless = headless
|
||||||
|
self.timeout = timeout
|
||||||
|
self.config = config or get_crawler_config()
|
||||||
|
self.browser: Optional[PlaywrightBrowser] = None
|
||||||
|
self.interceptor: Optional[ZhihuApiInterceptor] = None
|
||||||
|
|
||||||
|
def _get_cookie(self) -> str:
|
||||||
|
"""获取 Cookie"""
|
||||||
|
settings = get_settings()
|
||||||
|
return settings.zhihu_cookie.get_secret_value()
|
||||||
|
|
||||||
|
def _apply_random_delay(self) -> None:
|
||||||
|
"""应用随机延迟"""
|
||||||
|
delay = random.uniform(self.config.min_delay, self.config.max_delay)
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
def _start_browser_with_proxy(self, cookie: str) -> None:
|
||||||
|
"""启动浏览器并应用代理配置"""
|
||||||
|
# 验证代理
|
||||||
|
self.config.validate_proxy()
|
||||||
|
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
self.playwright = sync_playwright().start()
|
||||||
|
browser_args = {"headless": self.headless}
|
||||||
|
|
||||||
|
# 应用代理
|
||||||
|
context_args = {
|
||||||
|
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.config.proxy:
|
||||||
|
context_args["proxy"] = self.config.proxy.to_playwright_format()
|
||||||
|
|
||||||
|
# 阻止 Service Worker 干扰 API 拦截
|
||||||
|
context_args["service_workers"] = "block"
|
||||||
|
|
||||||
|
self.browser = self.playwright.chromium.launch(**browser_args)
|
||||||
|
self.context = self.browser.new_context(**context_args)
|
||||||
|
|
||||||
|
# 添加 Cookie
|
||||||
|
if cookie:
|
||||||
|
self._add_cookies(cookie)
|
||||||
|
|
||||||
|
self.page = self.context.new_page()
|
||||||
|
self.page.set_default_timeout(self.timeout * 1000)
|
||||||
|
|
||||||
|
def _add_cookies(self, cookie_str: str) -> None:
|
||||||
|
"""添加 Cookie 到浏览器上下文"""
|
||||||
|
cookies = []
|
||||||
|
for item in cookie_str.split(';'):
|
||||||
|
item = item.strip()
|
||||||
|
if '=' in item:
|
||||||
|
name, value = item.split('=', 1)
|
||||||
|
cookies.append({
|
||||||
|
'name': name.strip(),
|
||||||
|
'value': value.strip(),
|
||||||
|
'domain': '.zhihu.com',
|
||||||
|
'path': '/',
|
||||||
|
'secure': True,
|
||||||
|
'httpOnly': False,
|
||||||
|
'sameSite': 'Lax'
|
||||||
|
})
|
||||||
|
self.context.add_cookies(cookies)
|
||||||
|
|
||||||
|
def _close_browser(self) -> None:
|
||||||
|
"""关闭浏览器"""
|
||||||
|
if hasattr(self, 'page') and self.page:
|
||||||
|
self.page.close()
|
||||||
|
if hasattr(self, 'context') and self.context:
|
||||||
|
self.context.close()
|
||||||
|
if hasattr(self, 'browser') and self.browser:
|
||||||
|
self.browser.close()
|
||||||
|
if hasattr(self, 'playwright'):
|
||||||
|
self.playwright.stop()
|
||||||
|
|
||||||
|
def _crawl_with_retry(self, crawl_func, *args, **kwargs):
|
||||||
|
"""带重试的爬取"""
|
||||||
|
try:
|
||||||
|
for attempt in Retrying(
|
||||||
|
stop=stop_after_attempt(self.config.max_retries),
|
||||||
|
wait=wait_exponential(
|
||||||
|
multiplier=1,
|
||||||
|
min=self.config.retry_min_wait,
|
||||||
|
max=self.config.retry_max_wait
|
||||||
|
),
|
||||||
|
reraise=True
|
||||||
|
):
|
||||||
|
with attempt:
|
||||||
|
# 随机延迟
|
||||||
|
self._apply_random_delay()
|
||||||
|
return crawl_func(*args, **kwargs)
|
||||||
|
except RetryError as e:
|
||||||
|
return CrawlResult(
|
||||||
|
success=False,
|
||||||
|
error=f"重试 {self.config.max_retries} 次后仍失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def crawl_answer(self, url: str, include_comments: bool = False) -> CrawlResult:
|
||||||
|
"""爬取知乎回答(带重试)"""
|
||||||
|
return self._crawl_with_retry(self._crawl_answer_impl, url, include_comments)
|
||||||
|
|
||||||
|
def _crawl_answer_impl(self, url: str, include_comments: bool = False) -> CrawlResult:
|
||||||
|
"""爬取知乎回答实现"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
match = re.search(self.ANSWER_PATTERN, url)
|
||||||
|
if not match:
|
||||||
|
return CrawlResult(success=False, error="Invalid answer URL")
|
||||||
|
|
||||||
|
question_id, answer_id = match.groups()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._start_browser_with_proxy(self._get_cookie())
|
||||||
|
self.interceptor = ZhihuApiInterceptor()
|
||||||
|
|
||||||
|
# 设置路由拦截 - x-zse-96 签名由浏览器自动处理
|
||||||
|
self.page.route("**/api/v4/**", self.interceptor.handle_route)
|
||||||
|
|
||||||
|
# 访问页面触发 API 请求
|
||||||
|
self.page.goto(url, wait_until="networkidle")
|
||||||
|
|
||||||
|
# 等待数据捕获
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
if "answer" not in self.interceptor.captured_data:
|
||||||
|
return CrawlResult(success=False, error="Failed to capture answer data")
|
||||||
|
|
||||||
|
answer_data = self.interceptor.captured_data["answer"]
|
||||||
|
|
||||||
|
result_data = AnswerData(
|
||||||
|
id=int(answer_id),
|
||||||
|
question_id=int(question_id),
|
||||||
|
question_title=answer_data.get("question", {}).get("title", ""),
|
||||||
|
content=answer_data.get("content", ""),
|
||||||
|
excerpt=answer_data.get("excerpt", ""),
|
||||||
|
metadata=self._parse_metadata(answer_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = CrawlResult(success=True, content_type="answer", data=result_data)
|
||||||
|
|
||||||
|
# 可选:爬取评论
|
||||||
|
if include_comments:
|
||||||
|
comments_result = self.crawl_comments(
|
||||||
|
content_type="answers",
|
||||||
|
content_id=int(answer_id)
|
||||||
|
)
|
||||||
|
result.comments = comments_result.comments
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return CrawlResult(success=False, error=str(e))
|
||||||
|
finally:
|
||||||
|
self._close_browser()
|
||||||
|
|
||||||
|
def crawl_article(self, url: str, include_comments: bool = False) -> CrawlResult:
|
||||||
|
"""爬取知乎专栏文章(带重试)"""
|
||||||
|
return self._crawl_with_retry(self._crawl_article_impl, url, include_comments)
|
||||||
|
|
||||||
|
def _crawl_article_impl(self, url: str, include_comments: bool = False) -> CrawlResult:
|
||||||
|
"""爬取知乎专栏文章实现"""
|
||||||
|
# ... 类似 crawl_answer 的实现 ...
|
||||||
|
pass
|
||||||
|
|
||||||
|
# crawl_comments 方法保持不变
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 创建 tests/test_crawler/test_rate_limiter.py:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
import time
|
||||||
|
from unittest.mock import Mock, patch, MagicMock
|
||||||
|
|
||||||
|
from kb_workflow.crawler.zhihu_crawler import ZhihuCrawler
|
||||||
|
from kb_workflow.crawler.config import CrawlerConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TestRateLimiter:
|
||||||
|
|
||||||
|
def test_random_delay_applied(self):
|
||||||
|
"""请求间有随机延迟"""
|
||||||
|
config = CrawlerConfig(
|
||||||
|
min_delay=0.1,
|
||||||
|
max_delay=0.2,
|
||||||
|
proxy_required=False
|
||||||
|
)
|
||||||
|
crawler = ZhihuCrawler(config=config)
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
crawler._apply_random_delay()
|
||||||
|
elapsed = time.time() - start
|
||||||
|
|
||||||
|
assert 0.1 <= elapsed <= 0.25 # 允许少量误差
|
||||||
|
|
||||||
|
def test_delay_is_random(self):
|
||||||
|
"""延迟是随机的"""
|
||||||
|
config = CrawlerConfig(
|
||||||
|
min_delay=0.05,
|
||||||
|
max_delay=0.15,
|
||||||
|
proxy_required=False
|
||||||
|
)
|
||||||
|
crawler = ZhihuCrawler(config=config)
|
||||||
|
|
||||||
|
delays = []
|
||||||
|
for _ in range(10):
|
||||||
|
start = time.time()
|
||||||
|
crawler._apply_random_delay()
|
||||||
|
delays.append(time.time() - start)
|
||||||
|
|
||||||
|
# 延迟应该有变化
|
||||||
|
assert max(delays) - min(delays) > 0.01
|
||||||
|
|
||||||
|
def test_exponential_backoff_retry(self):
|
||||||
|
"""重试间隔递增"""
|
||||||
|
config = CrawlerConfig(
|
||||||
|
max_retries=3,
|
||||||
|
retry_min_wait=0.1,
|
||||||
|
retry_max_wait=1.0,
|
||||||
|
proxy_required=False
|
||||||
|
)
|
||||||
|
crawler = ZhihuCrawler(config=config)
|
||||||
|
|
||||||
|
call_times = []
|
||||||
|
|
||||||
|
def failing_func():
|
||||||
|
call_times.append(time.time())
|
||||||
|
raise Exception("Simulated failure")
|
||||||
|
|
||||||
|
result = crawler._crawl_with_retry(failing_func)
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert "重试 3 次后仍失败" in result.error
|
||||||
|
assert len(call_times) == 3 # 初始 + 2 次重试
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. 创建 tests/test_crawler/test_api_intercept.py:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, MagicMock
|
||||||
|
|
||||||
|
from kb_workflow.crawler.zhihu_crawler import ZhihuApiInterceptor
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiIntercept:
|
||||||
|
|
||||||
|
def test_intercept_answer_api(self):
|
||||||
|
"""拦截答案 API"""
|
||||||
|
interceptor = ZhihuApiInterceptor()
|
||||||
|
|
||||||
|
# 模拟 route 对象
|
||||||
|
route = Mock()
|
||||||
|
route.request.url = "https://www.zhihu.com/api/v4/answers/12345"
|
||||||
|
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.json.return_value = {"id": 12345, "content": "test"}
|
||||||
|
route.fetch.return_value = mock_response
|
||||||
|
|
||||||
|
interceptor.handle_route(route)
|
||||||
|
|
||||||
|
assert "answer" in interceptor.captured_data
|
||||||
|
assert interceptor.captured_data["answer"]["id"] == 12345
|
||||||
|
|
||||||
|
def test_intercept_article_api(self):
|
||||||
|
"""拦截文章 API"""
|
||||||
|
interceptor = ZhihuApiInterceptor()
|
||||||
|
|
||||||
|
route = Mock()
|
||||||
|
route.request.url = "https://www.zhihu.com/api/v4/articles/67890"
|
||||||
|
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.json.return_value = {"id": 67890, "title": "test article"}
|
||||||
|
route.fetch.return_value = mock_response
|
||||||
|
|
||||||
|
interceptor.handle_route(route)
|
||||||
|
|
||||||
|
assert "article" in interceptor.captured_data
|
||||||
|
|
||||||
|
def test_non_api_requests_continue(self):
|
||||||
|
"""非 API 请求继续正常加载"""
|
||||||
|
interceptor = ZhihuApiInterceptor()
|
||||||
|
|
||||||
|
route = Mock()
|
||||||
|
route.request.url = "https://www.zhihu.com/static/some.js"
|
||||||
|
|
||||||
|
interceptor.handle_route(route)
|
||||||
|
|
||||||
|
route.continue_.assert_called_once()
|
||||||
|
route.fulfill.assert_not_called()
|
||||||
|
|
||||||
|
def test_x_zse_96_auto_handled(self):
|
||||||
|
"""x-zse-96 签名由浏览器自动处理(通过 route.fetch)"""
|
||||||
|
interceptor = ZhihuApiInterceptor()
|
||||||
|
|
||||||
|
route = Mock()
|
||||||
|
route.request.url = "https://www.zhihu.com/api/v4/answers/123"
|
||||||
|
|
||||||
|
interceptor.handle_route(route)
|
||||||
|
|
||||||
|
# fetch() 会自动带上浏览器生成的签名
|
||||||
|
route.fetch.assert_called_once()
|
||||||
|
# fulfill() 返回响应给页面
|
||||||
|
route.fulfill.assert_called_once()
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pytest tests/test_crawler/test_rate_limiter.py tests/test_crawler/test_api_intercept.py -x --tb=short</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- 代理配置整合到 ZhihuCrawler
|
||||||
|
- 随机延迟 1-3 秒生效
|
||||||
|
- 失败重试 3 次,间隔递增
|
||||||
|
- x-zse-96 签名由浏览器自动处理
|
||||||
|
- 所有测试通过
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
**自动化验证:**
|
||||||
|
```bash
|
||||||
|
pytest tests/test_crawler/ -x -v
|
||||||
|
```
|
||||||
|
|
||||||
|
**代理配置验证:**
|
||||||
|
```bash
|
||||||
|
# 不配置代理时应报错
|
||||||
|
unset HTTP_PROXY HTTPS_PROXY
|
||||||
|
python -c "from kb_workflow.crawler import ZhihuCrawler; ZhihuCrawler().crawl_answer('https://www.zhihu.com/question/1/answer/1')"
|
||||||
|
# 应输出: ValueError: 代理配置缺失!
|
||||||
|
```
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
1. 代理配置从 HTTP_PROXY/HTTPS_PROXY 读取
|
||||||
|
2. proxy_required=True 时未配置代理抛出异常
|
||||||
|
3. 请求间有随机延迟 (1-3 秒)
|
||||||
|
4. 失败请求重试 3 次,间隔递增
|
||||||
|
5. x-zse-96 签名由浏览器自动处理
|
||||||
|
6. 所有测试通过
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/08-crawler/08-03-SUMMARY.md`
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user