--- 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`