diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 2ccbf75..13359d7 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -90,12 +90,12 @@ Plans: 2. 扫码失败时用户可以手动设置 Cookie 进行认证 3. Cookie 自动保存到本地文件,下次运行自动复用 4. Cookie 过期时系统自动检测并提示用户重新登录 -**Plans**: 3 plans +**Plans**: 3 plans (all complete) Plans: -- [ ] 07-01-PLAN.md — 二维码扫码登录核心逻辑 (AUTH-01) + Wave 0 测试脚手架 -- [ ] 07-02-PLAN.md — Cookie 导入和持久化管理 (AUTH-02, AUTH-03) -- [ ] 07-03-PLAN.md — Cookie 验证 + CLI login 子命令 (AUTH-04) +- [x] 07-01-PLAN.md — 二维码扫码登录核心逻辑 (AUTH-01) + Wave 0 测试脚手架 +- [x] 07-02-PLAN.md — Cookie 导入和持久化管理 (AUTH-02, AUTH-03) +- [x] 07-03-PLAN.md — Cookie 验证 + CLI login 子命令 (AUTH-04) #### Phase 8: 内容爬取实现 **Goal**: 实现知乎问答、专栏/文章、评论的完整爬取功能,包含网络反爬措施 @@ -107,12 +107,12 @@ Plans: 3. 用户可以选择是否爬取内容下的评论 4. 爬取结果包含作者信息、发布时间、点赞数等完整元数据 5. 用户可以配置代理 IP 以降低被封风险 -**Plans**: TBD +**Plans**: 3 plans in 2 waves Plans: -- [ ] 08-01: 问答和专栏内容爬取 (CONT-01, CONT-02, CONT-04) -- [ ] 08-02: 评论爬取功能 (CONT-03) -- [ ] 08-03: 网络反爬措施 (NET-01, NET-02, NET-03) +- [ ] 08-01-PLAN.md — 问答和专栏内容爬取 (CONT-01, CONT-02, CONT-04) — Wave 1 +- [ ] 08-02-PLAN.md — 评论爬取功能 (CONT-03) — Wave 1 (parallel) +- [ ] 08-03-PLAN.md — 网络反爬措施 (NET-01, NET-02, NET-03) — Wave 2 #### Phase 9: 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 | | 5. 归档与目录优化 | 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 | - | -| 8. 内容爬取实现 | v1.2 | 0/3 | Not started | - | +| 7. 登录认证体系 | v1.2 | 3/3 | Complete | 2026-03-17 | +| 8. 内容爬取实现 | v1.2 | 0/3 | Ready to execute | - | | 9. Pipeline 整合 | v1.2 | 0/1 | Not started | - | diff --git a/.planning/phases/08-crawler/08-01-PLAN.md b/.planning/phases/08-crawler/08-01-PLAN.md new file mode 100644 index 0000000..40f93bf --- /dev/null +++ b/.planning/phases/08-crawler/08-01-PLAN.md @@ -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" +--- + + +实现知乎问答和专栏/文章的内容爬取功能,使用 Playwright 拦截 API 请求获取结构化 JSON 数据。 + +Purpose: 为 Pipeline 提供可靠的知乎内容爬取能力,复用 Phase 7 的 Cookie 管理 +Output: ZhihuCrawler 类 + 数据模型 + 测试 + + + +@/home/starmen1265/.claude/get-shit-done/workflows/execute-plan.md +@/home/starmen1265/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +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` + + + + + + + Task 1: 创建数据模型和测试脚手架 + kb_workflow/crawler/__init__.py, kb_workflow/crawler/models.py, tests/test_crawler/__init__.py, tests/test_crawler/conftest.py + +创建 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:** 导出主要类 + + + pytest tests/test_crawler/ -x --tb=short -k "conftest or init" + Verify models.py can be imported: python -c "from kb_workflow.crawler.models import AnswerData; print('OK')" + + + - models.py 包含 AnswerData, ArticleData, ContentMetadata, AuthorInfo 类 + - conftest.py 包含 mock_page, mock_browser fixtures + - 所有模块可正常导入 + + + + + Task 2: 实现 ZhihuCrawler 核心类 + kb_workflow/crawler/zhihu_crawler.py, tests/test_crawler/test_answer_crawler.py, tests/test_crawler/test_article_crawler.py + + - Test 1: crawl_answer() 返回 AnswerData,包含问题标题、回答内容、元数据 + - Test 2: crawl_article() 返回 ArticleData,包含标题、正文、元数据 + - Test 3: 无效 URL 返回 CrawlResult(success=False, error=...) + - Test 4: API 拦截正确捕获 /api/v4/answers/ 响应 + + +实现 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: 测试元数据提取 + + + 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 + + + - ZhihuCrawler.crawl_answer() 返回 CrawlResult 包含 AnswerData + - ZhihuCrawler.crawl_article() 返回 CrawlResult 包含 ArticleData + - 元数据正确提取作者、点赞数、评论数 + - 所有测试通过 + + + + + + +**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 +``` + + + +1. ZhihuCrawler 类可正常导入 +2. crawl_answer() 能爬取知乎问答并返回结构化数据 +3. crawl_article() 能爬取知乎专栏并返回结构化数据 +4. 数据包含作者、点赞数、评论数等完整元数据 +5. 所有测试通过 + + + +After completion, create `.planning/phases/08-crawler/08-01-SUMMARY.md` + diff --git a/.planning/phases/08-crawler/08-02-PLAN.md b/.planning/phases/08-crawler/08-02-PLAN.md new file mode 100644 index 0000000..671ae14 --- /dev/null +++ b/.planning/phases/08-crawler/08-02-PLAN.md @@ -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" +--- + + +实现知乎评论爬取功能,通过热门评论 API 获取第一层评论,作为可选功能集成到 ZhihuCrawler。 + +Purpose: 提供可选的评论爬取能力,丰富内容元数据 +Output: crawl_comments() 方法 + CommentData 模型 + 测试 + + + +@/home/starmen1265/.claude/get-shit-done/workflows/execute-plan.md +@/home/starmen1265/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/08-crawler/08-CONTEXT.md +@.planning/phases/08-crawler/08-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": {...} +} +``` + + + + + + + Task 1: 添加 CommentData 数据模型 + kb_workflow/crawler/models.py + +在 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 +``` + + + python -c "from kb_workflow.crawler.models import CommentData, CommentsResult; print('OK')" + + + - CommentData 模型定义完成 + - CommentsResult 模型定义完成 + - CrawlResult 支持 comments 字段 + + + + + Task 2: 实现 crawl_comments 方法 + kb_workflow/crawler/zhihu_crawler.py, tests/test_crawler/test_comment_crawler.py + + - 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 中触发评论爬取 + + +在 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 +``` + + + pytest tests/test_crawler/test_comment_crawler.py -x --tb=short + + + - crawl_comments() 方法返回 CommentsResult + - 评论使用 order=hot 排序 + - 只返回第一层评论 + - include_comments 参数控制是否爬取 + - 所有测试通过 + + + + + + +**自动化验证:** +```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 +``` + + + +1. CommentData 模型定义完成 +2. crawl_comments() 方法实现完成 +3. 评论使用热门排序 (order=hot) +4. 只爬取第一层评论,不展开嵌套 +5. include_comments 参数控制评论爬取 +6. 所有测试通过 + + + +After completion, create `.planning/phases/08-crawler/08-02-SUMMARY.md` + diff --git a/.planning/phases/08-crawler/08-03-PLAN.md b/.planning/phases/08-crawler/08-03-PLAN.md new file mode 100644 index 0000000..008e914 --- /dev/null +++ b/.planning/phases/08-crawler/08-03-PLAN.md @@ -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" +--- + + +实现网络反爬措施:强制代理配置、请求频率控制、有限重试机制,以及利用浏览器自动处理 x-zse-96 签名。 + +Purpose: 降低被封风险,提高爬取稳定性 +Output: CrawlerConfig + 代理/延迟/重试逻辑 + 测试 + + + +@/home/starmen1265/.claude/get-shit-done/workflows/execute-plan.md +@/home/starmen1265/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +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: + # 爬取逻辑 +``` + + + + + + + Task 1: 创建 CrawlerConfig 配置类 + kb_workflow/crawler/config.py, tests/test_crawler/test_proxy_config.py + +创建爬虫配置类,处理代理和延迟设置。 + +**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" + } +``` + + + pytest tests/test_crawler/test_proxy_config.py -x --tb=short + + + - CrawlerConfig 类定义完成 + - 从 HTTP_PROXY/HTTPS_PROXY 读取代理 + - proxy_required=True 时未配置代理抛出异常 + - 所有测试通过 + + + + + Task 2: 整合代理和延迟到 ZhihuCrawler + kb_workflow/crawler/zhihu_crawler.py, tests/test_crawler/test_rate_limiter.py, tests/test_crawler/test_api_intercept.py + + - Test 1: 未配置代理且 proxy_required=True 时抛出异常 + - Test 2: 请求间有随机延迟 (1-3 秒) + - Test 3: 失败请求重试 3 次,间隔递增 + - Test 4: API 拦截正确处理 x-zse-96 签名(由浏览器自动处理) + + +更新 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() +``` + + + pytest tests/test_crawler/test_rate_limiter.py tests/test_crawler/test_api_intercept.py -x --tb=short + + + - 代理配置整合到 ZhihuCrawler + - 随机延迟 1-3 秒生效 + - 失败重试 3 次,间隔递增 + - x-zse-96 签名由浏览器自动处理 + - 所有测试通过 + + + + + + +**自动化验证:** +```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: 代理配置缺失! +``` + + + +1. 代理配置从 HTTP_PROXY/HTTPS_PROXY 读取 +2. proxy_required=True 时未配置代理抛出异常 +3. 请求间有随机延迟 (1-3 秒) +4. 失败请求重试 3 次,间隔递增 +5. x-zse-96 签名由浏览器自动处理 +6. 所有测试通过 + + + +After completion, create `.planning/phases/08-crawler/08-03-SUMMARY.md` +