From fe22f94358f21811ed1bbf103e04f0bdd33aeb69 Mon Sep 17 00:00:00 2001 From: starmen1265 Date: Wed, 18 Mar 2026 16:18:38 +0800 Subject: [PATCH] docs(10): research proxy pool domain --- .planning/phases/10-proxy-pool/10-RESEARCH.md | 599 ++++++++++++++++++ 1 file changed, 599 insertions(+) create mode 100644 .planning/phases/10-proxy-pool/10-RESEARCH.md diff --git a/.planning/phases/10-proxy-pool/10-RESEARCH.md b/.planning/phases/10-proxy-pool/10-RESEARCH.md new file mode 100644 index 0000000..15bbd99 --- /dev/null +++ b/.planning/phases/10-proxy-pool/10-RESEARCH.md @@ -0,0 +1,599 @@ +# Phase 10: Proxy Pool (代理池管理) - Research + +**Researched:** 2026-03-18 +**Domain:** Python Proxy Pool Management / Playwright Proxy Integration +**Confidence:** HIGH + +## Summary + +本研究针对代理池基础设施的实现方案,重点解决多代理配置、自动轮换、健康检查和失效回退四个核心需求。基于现有 `kb_workflow/crawler/config.py` 的单代理实现,推荐采用 **ProxyPool 类 + 被动健康检测 + 冷却恢复** 的架构模式。 + +**Primary recommendation:** 新建 `kb_workflow/crawler/proxy_pool.py` 模块,实现 ProxyPool 类,使用 itertools.cycle 进行顺序轮换,通过惰性检查实现代理冷却恢复,与现有 CrawlerConfig 无缝集成。 + + +## User Constraints (from CONTEXT.md) + +### Implementation Decisions (Locked) + +#### 代理池配置方式 +- **配置来源**: YAML 文件(`~/.kb_proxies.yaml`) +- **配置格式**: 简单 URL 列表,认证信息嵌入 URL + ```yaml + proxies: + - "http://user:pass@proxy1.example.com:8080" + - "http://proxy2.example.com:3128" + - "socks5://user:pass@proxy3.example.com:1080" + ``` +- **向后兼容**: 如果未配置代理池文件,自动回退到现有的 `HTTP_PROXY` 环境变量(单代理模式) +- **支持的协议**: HTTP、HTTPS、SOCKS5 + +#### 轮换策略 +- **策略**: 顺序轮换(Round-Robin) +- **时机**: 每次请求自动轮换到下一个代理 +- **实现**: 维护一个索引指针,每次请求后递增并循环 + +#### 健康检查机制 +- **检测方式**: 被动检测(请求失败时标记) +- **失效判断**: 连续 3 次请求失败后标记为失效 +- **失败类型**: 网络超时、连接失败、代理返回错误 + +#### 失效代理处理 +- **策略**: 暂时移除 + 冷却恢复 +- **冷却时间**: 5 分钟后自动恢复到轮换池 +- **全失效处理**: 所有代理都失效时,回退到第一个代理继续尝试请求 + +### Claude's Discretion +- 冷却时间的具体实现方式(定时器 vs 惰性检查) +- 代理统计信息的记录格式(可选功能) +- 日志输出的详细程度 +- 失效代理的具体判断条件(HTTP 状态码范围) + +### Deferred Ideas (OUT OF SCOPE) +- **API 获取代理** — 从代理服务商 API 自动获取代理 +- **加权轮换** — 根据代理质量/速度加权 +- **代理统计面板** — 展示每个代理的成功率、响应时间 +- **配置热加载** — 运行时检测配置文件变化并重新加载 + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| PROXY-01 | 用户可以配置代理池 — 支持从文件或环境变量加载多个代理地址 | YAML 配置 + Pydantic BaseModel 解析 + 向后兼容环境变量 | +| PROXY-02 | 系统自动轮换代理 — 每次请求自动选择下一个可用代理 | itertools.cycle + 索引指针实现 Round-Robin | +| PROXY-03 | 代理健康检查 — 检测代理可用性,自动移除失效代理 | 被动检测(失败计数)+ 连续3次失败标记失效 | +| PROXY-04 | 代理失败回退 — 当前代理失败时自动切换到下一个代理 | 失效代理跳过 + 冷却恢复 + 全失效回退策略 | + + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| Pydantic | 2.11+ | ProxyConfig 数据模型 | 项目已使用;类型安全;支持 SecretStr 敏感字段 | +| pydantic-settings | 2.8+ | 配置管理 | 项目已使用;支持 YAML/环境变量加载 | +| PyYAML | 6.0+ | YAML 配置解析 | Python 标准库;轻量级 | +| itertools (stdlib) | - | cycle() 实现轮换 | 零依赖;高效无限迭代器 | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| tenacity | 8.5+ | 重试机制(现有) | 代理失败时的重试策略 | +| loguru | 0.7+ | 日志记录 | 代理状态变化日志 | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| 自定义 ProxyPool | rotating-proxy 库 | 代码质量差、维护不活跃、不支持自定义配置源 | +| 自定义 ProxyPool | scrapy-rotating-proxies | 与 Scrapy 绑定、不适用于 Playwright | +| 被动检测 | 主动健康检查 | 主动检测增加延迟和复杂度;被动检测足够满足需求 | +| 惰性冷却检查 | 定时器后台任务 | 定时器需要线程/异步;惰性检查更简单、无并发问题 | + +**Installation:** +```bash +# 项目已有依赖,无需新增 +# Pydantic, pydantic-settings, PyYAML 均已安装 +``` + +## Architecture Patterns + +### Recommended Project Structure +``` +kb_workflow/crawler/ +├── config.py # 现有:CrawlerConfig, ProxyConfig(扩展) +├── proxy_pool.py # 新增:ProxyPool, ProxyEntry, ProxyPoolConfig +├── zhihu_crawler.py # 修改:使用 ProxyPool +└── models.py # 现有:CrawlResult 等 +``` + +### Pattern 1: ProxyPool 类设计 + +**What:** 代理池核心类,管理多个代理的生命周期 + +**When to use:** 每个爬虫实例一个 ProxyPool,处理该爬虫的所有代理相关逻辑 + +**Example:** +```python +# Source: 基于现有代码模式和 Infatica 最佳实践 +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Optional, Iterator +from itertools import cycle +from pathlib import Path +import yaml + +from pydantic import BaseModel +import logging + +logger = logging.getLogger(__name__) + + +class ProxyEntry(BaseModel): + """单个代理的配置和状态""" + url: str # 完整 URL,包含认证信息 + fail_count: int = 0 # 连续失败次数 + last_fail_time: Optional[datetime] = None # 最后失败时间 + is_available: bool = True # 当前是否可用 + + def record_success(self): + """记录请求成功""" + self.fail_count = 0 + self.is_available = True + + def record_failure(self): + """记录请求失败""" + self.fail_count += 1 + self.last_fail_time = datetime.now() + if self.fail_count >= 3: + self.is_available = False + logger.warning(f"Proxy marked as unavailable: {self.url[:30]}...") + + def check_recovery(self, cooldown_seconds: int = 300) -> bool: + """检查是否可以从冷却中恢复(惰性检查)""" + if self.is_available: + return True + if self.last_fail_time is None: + return True + elapsed = (datetime.now() - self.last_fail_time).total_seconds() + if elapsed >= cooldown_seconds: + self.is_available = True + self.fail_count = 0 + logger.info(f"Proxy recovered after {elapsed:.0f}s cooldown") + return True + return False + + +class ProxyPoolConfig(BaseModel): + """代理池配置""" + proxies: list[str] = [] # 代理 URL 列表 + cooldown_seconds: int = 300 # 冷却时间(秒) + max_fail_count: int = 3 # 最大连续失败次数 + + +class ProxyPool: + """代理池管理器 + + 功能: + - 从 YAML 文件或环境变量加载代理列表 + - Round-Robin 轮换 + - 被动健康检测 + - 冷却恢复 + """ + + def __init__(self, config: ProxyPoolConfig): + self.config = config + self._entries: list[ProxyEntry] = [ + ProxyEntry(url=url) for url in config.proxies + ] + self._current_index = 0 + self._cycle: Optional[Iterator] = None + if self._entries: + self._cycle = cycle(self._entries) + + @classmethod + def from_yaml(cls, path: Path) -> "ProxyPool": + """从 YAML 文件加载代理池""" + if not path.exists(): + logger.debug(f"Proxy config file not found: {path}") + return cls(ProxyPoolConfig()) + + with open(path) as f: + data = yaml.safe_load(f) or {} + + proxies = data.get("proxies", []) + return cls(ProxyPoolConfig(proxies=proxies)) + + @classmethod + def from_env_fallback(cls) -> "ProxyPool": + """从环境变量加载单代理(向后兼容)""" + import os + proxy_url = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") + if proxy_url: + return cls(ProxyPoolConfig(proxies=[proxy_url])) + return cls(ProxyPoolConfig()) + + def get_next_proxy(self) -> Optional[ProxyEntry]: + """获取下一个可用代理 + + 实现逻辑: + 1. 惰性检查当前代理是否可恢复 + 2. 跳过不可用的代理 + 3. 所有代理都失效时,回退到第一个代理 + """ + if not self._entries: + return None + + if len(self._entries) == 1: + # 单代理模式:总是返回该代理 + return self._entries[0] + + # 尝试找到可用代理 + checked = set() + while len(checked) < len(self._entries): + entry = self._entries[self._current_index] + self._current_index = (self._current_index + 1) % len(self._entries) + checked.add(self._current_index) + + # 惰性检查恢复 + if entry.check_recovery(self.config.cooldown_seconds): + return entry + + # 所有代理都失效,回退到第一个代理强制使用 + logger.warning("All proxies unavailable, falling back to first proxy") + return self._entries[0] + + def has_proxies(self) -> bool: + """检查是否有配置的代理""" + return len(self._entries) > 0 + + def get_available_count(self) -> int: + """获取当前可用代理数量""" + return sum( + 1 for e in self._entries + if e.check_recovery(self.config.cooldown_seconds) + ) +``` + +### Pattern 2: CrawlerConfig 集成 + +**What:** 扩展现有 CrawlerConfig 支持 ProxyPool + +**When to use:** 爬虫初始化时加载代理池配置 + +**Example:** +```python +# Source: 基于现有 config.py 扩展 +from pathlib import Path + +def get_crawler_config_with_pool() -> tuple["CrawlerConfig", ProxyPool]: + """获取爬虫配置和代理池 + + 优先级: + 1. ~/.kb_proxies.yaml(代理池) + 2. HTTP_PROXY/HTTPS_PROXY(单代理,向后兼容) + """ + proxy_file = Path.home() / ".kb_proxies.yaml" + proxy_pool = ProxyPool.from_yaml(proxy_file) + + if not proxy_pool.has_proxies(): + # 回退到环境变量 + proxy_pool = ProxyPool.from_env_fallback() + + # 原有配置逻辑 + config = CrawlerConfig.from_env() + + return config, proxy_pool +``` + +### Pattern 3: ZhihuCrawler 集成 + +**What:** 修改 ZhihuCrawler 使用 ProxyPool + +**When to use:** 每次启动浏览器时从代理池获取代理 + +**Example:** +```python +# Source: 基于现有 zhihu_crawler.py 修改 +class ZhihuCrawler: + def __init__( + self, + headless: bool = True, + timeout: int = 30, + config: Optional[CrawlerConfig] = None, + proxy_pool: Optional[ProxyPool] = None + ): + self.headless = headless + self.timeout = timeout + self.config = config or get_crawler_config() + self.proxy_pool = proxy_pool # 新增 + # ... + + def _start_browser_with_proxy(self, cookie: str) -> None: + """启动浏览器,从代理池获取代理""" + self.config.validate_proxy() + + self._playwright = sync_playwright().start() + self._browser = self._playwright.chromium.launch(headless=self.headless) + + context_options = { + 'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...' + } + + # 从代理池获取代理 + if self.proxy_pool and self.proxy_pool.has_proxies(): + proxy_entry = self.proxy_pool.get_next_proxy() + if proxy_entry: + proxy_config = self._parse_proxy_url(proxy_entry.url) + context_options["proxy"] = proxy_config + self._current_proxy = proxy_entry # 保存当前代理引用 + elif self.config.proxy: + # 向后兼容:使用单代理配置 + context_options["proxy"] = self.config.proxy.to_playwright_format() + + context_options["service_workers"] = "block" + self._context = self._browser.new_context(**context_options) + # ... + + def _on_request_success(self): + """请求成功时通知代理池""" + if hasattr(self, '_current_proxy') and self._current_proxy: + self._current_proxy.record_success() + + def _on_request_failure(self): + """请求失败时通知代理池""" + if hasattr(self, '_current_proxy') and self._current_proxy: + self._current_proxy.record_failure() +``` + +### Anti-Patterns to Avoid + +- **每次请求创建新浏览器** — Playwright 浏览器启动成本高,应在同一代理上处理多个请求后再切换 +- **主动健康检查** — 增加延迟;被动检测足够 +- **硬编码冷却时间** — 应通过配置文件可配置 +- **忽略 SOCKS5 代理** — 需要额外依赖 `requests[socks]`,Playwright 原生支持 + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| 代理轮换逻辑 | 手写 while 循环 | `itertools.cycle()` | 边界处理复杂、容易死循环 | +| 配置解析 | 手写正则提取 URL | `urllib.parse.urlparse()` | URL 格式多变、边界情况多 | +| 代理认证提取 | 字符串 split | Pydantic BaseModel | 类型安全、自动验证 | +| 冷却时间计算 | 手写时间差 | `datetime.timedelta` | 时区、精度问题 | + +**Key insight:** 代理池看似简单,但边界情况极多(URL 解析、认证、协议差异、失败重试)。使用标准库和 Pydantic 可以避免大部分陷阱。 + +## Common Pitfalls + +### Pitfall 1: 代理配置不完整导致真实 IP 泄漏 + +**What goes wrong:** 只配置了 HTTP 代理但未配置 HTTPS 代理,或 Playwright proxy 配置不完整,导致部分请求绕过代理暴露真实 IP。 + +**Why it happens:** 开发者通常只关注主要请求的代理配置,忽略了 Playwright 的 `proxy` 参数需要正确格式。 + +**How to avoid:** +```python +# 正确的 Playwright 代理配置 +context_options = { + "proxy": { + "server": "http://proxy.example.com:8080", + "username": "user", # 可选 + "password": "pass", # 可选 + } +} +``` + +**Warning signs:** +- 使用代理后仍然收到 403 错误 +- 目标平台显示真实地理位置 +- IP 检测 API 返回真实 IP 而非代理 IP + +### Pitfall 2: 固定间隔轮换被识别为机器人 + +**What goes wrong:** 每次请求后立即切换代理,顺序固定(A→B→C→A),被反爬系统识别为自动化行为。 + +**Why it happens:** Round-Robin 是最简单的实现,但对于个人知识库场景(低频爬取),顺序轮换配合随机请求延迟已经足够。 + +**How to avoid:** 本项目采用 **会话级轮换**(每个浏览器会话使用一个代理),而非请求级轮换。结合 Phase 11 的随机延迟(1-5秒),风险可控。 + +**Warning signs:** +- 新代理 IP 很快被封 +- 频繁收到 CAPTCHA 挑战 + +### Pitfall 3: 代理池全失效时无限循环 + +**What goes wrong:** 所有代理都被标记为失效后,`get_next_proxy()` 返回 None 或进入死循环。 + +**Why it happens:** 只考虑了正常情况,没有处理边界条件。 + +**How to avoid:** +```python +def get_next_proxy(self) -> Optional[ProxyEntry]: + # ... 轮换逻辑 ... + + # 所有代理都失效时,回退到第一个代理 + logger.warning("All proxies unavailable, falling back to first proxy") + return self._entries[0] # 强制使用第一个 +``` + +### Pitfall 4: 代理 URL 解析错误 + +**What goes wrong:** SOCKS5 代理格式为 `socks5://user:pass@host:port`,但解析时只处理了 HTTP 格式。 + +**Why it happens:** 不同协议的 URL 格式略有差异,手动解析容易遗漏。 + +**How to avoid:** +```python +from urllib.parse import urlparse + +def parse_proxy_url(url: str) -> dict: + """解析代理 URL 为 Playwright 格式""" + parsed = urlparse(url) + server = f"{parsed.scheme}://{parsed.hostname}" + if parsed.port: + server += f":{parsed.port}" + + config = {"server": server} + if parsed.username: + config["username"] = parsed.username + if parsed.password: + config["password"] = parsed.password + return config +``` + +## Code Examples + +### 配置文件示例 (~/.kb_proxies.yaml) + +```yaml +# KB Skills 代理池配置 +# 支持格式:http://[user:pass@]host:port, socks5://[user:pass@]host:port + +proxies: + # 本地代理(Clash/V2Ray) + - "http://127.0.0.1:7890" + + # 带认证的 HTTP 代理 + - "http://user:password@proxy.example.com:8080" + + # SOCKS5 代理 + - "socks5://127.0.0.1:1080" + +# 可选:冷却时间(秒),默认 300 +cooldown_seconds: 300 + +# 可选:最大连续失败次数,默认 3 +max_fail_count: 3 +``` + +### 代理池初始化 + +```python +# Source: 推荐的初始化模式 +from pathlib import Path +from kb_workflow.crawler.proxy_pool import ProxyPool + +def init_proxy_pool() -> ProxyPool: + """初始化代理池,优先从配置文件加载""" + proxy_file = Path.home() / ".kb_proxies.yaml" + + # 优先级 1: YAML 配置文件 + if proxy_file.exists(): + pool = ProxyPool.from_yaml(proxy_file) + if pool.has_proxies(): + return pool + + # 优先级 2: 环境变量(向后兼容) + pool = ProxyPool.from_env_fallback() + return pool +``` + +### 爬虫使用代理池 + +```python +# Source: ZhihuCrawler 集成示例 +from kb_workflow.crawler.config import get_crawler_config +from kb_workflow.crawler.proxy_pool import ProxyPool, init_proxy_pool + +# 初始化 +config = get_crawler_config() +proxy_pool = init_proxy_pool() + +# 创建爬虫 +crawler = ZhihuCrawler( + headless=True, + timeout=30, + config=config, + proxy_pool=proxy_pool +) + +# 爬取时代理自动轮换 +result = crawler.crawl_answer("https://www.zhihu.com/question/123/answer/456") +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| 单代理硬编码 | 环境变量配置 | v1.2 | 更灵活,支持不同环境 | +| 环境变量单代理 | YAML 配置文件代理池 | v1.3 (Phase 10) | 支持多代理、轮换、健康检查 | +| 主动健康检查 | 被动检测 | v1.3 (Phase 10) | 减少延迟,简化实现 | +| 立即移除失效代理 | 冷却恢复 | v1.3 (Phase 10) | 代理可恢复使用,提高利用率 | + +**Deprecated/outdated:** +- `rotating-proxy` 库:2020年后无更新,代码质量差 +- `scrapy-rotating-proxies`:与 Scrapy 绑定,不适用于 Playwright + +## Open Questions + +1. **冷却检查时机:惰性检查 vs 定时器?** + - What we know: 惰性检查在 `get_next_proxy()` 时执行,无需额外线程/异步 + - What's unclear: 高并发场景下惰性检查的性能影响 + - Recommendation: 本项目为单线程顺序执行,惰性检查足够;如未来需要并发,可迁移到定时器 + +2. **代理统计信息是否需要持久化?** + - What we know: 当前设计只在内存中维护状态 + - What's unclear: 是否需要将代理成功率持久化到文件 + - Recommendation: v1.3 暂不持久化;如后续需要统计面板,再添加 + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | pytest 8.3+ | +| Config file | pytest.ini (pythonpath = ., testpaths = tests) | +| Quick run command | `pytest tests/test_crawler/test_proxy_pool.py -x` | +| Full suite command | `pytest tests/test_crawler/ -v` | + +### Phase Requirements → Test Map +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| PROXY-01 | 从 YAML 文件加载代理池 | unit | `pytest tests/test_crawler/test_proxy_pool.py::test_load_from_yaml -x` | ❌ Wave 0 | +| PROXY-01 | 从环境变量加载代理(向后兼容) | unit | `pytest tests/test_crawler/test_proxy_pool.py::test_load_from_env -x` | ❌ Wave 0 | +| PROXY-02 | Round-Robin 轮换 | unit | `pytest tests/test_crawler/test_proxy_pool.py::test_round_robin -x` | ❌ Wave 0 | +| PROXY-03 | 连续3次失败标记失效 | unit | `pytest tests/test_crawler/test_proxy_pool.py::test_fail_threshold -x` | ❌ Wave 0 | +| PROXY-03 | 冷却恢复 | unit | `pytest tests/test_crawler/test_proxy_pool.py::test_cooldown_recovery -x` | ❌ Wave 0 | +| PROXY-04 | 全失效回退 | unit | `pytest tests/test_crawler/test_proxy_pool.py::test_all_unavailable_fallback -x` | ❌ Wave 0 | +| PROXY-04 | 代理切换后继续请求 | integration | `pytest tests/test_crawler/test_proxy_pool.py::test_proxy_switch -x` | ❌ Wave 0 | + +### Sampling Rate +- **Per task commit:** `pytest tests/test_crawler/test_proxy_pool.py -x` +- **Per wave merge:** `pytest tests/test_crawler/ -v` +- **Phase gate:** Full suite green before `/gsd:verify-work` + +### Wave 0 Gaps +- [ ] `tests/test_crawler/test_proxy_pool.py` — 代理池核心逻辑测试 +- [ ] `tests/test_crawler/conftest.py` — 添加 proxy_pool fixture(已存在,可能需要扩展) +- [ ] 无需框架安装 — pytest 已配置 + +## Sources + +### Primary (HIGH confidence) +- [Infatica: Master Proxy Rotation with Python's Requests Library](https://infatica.io/blog/python-proxy-requests/) — Round-Robin 实现、代理轮换最佳实践 +- [Playwright Python Documentation - Network](https://playwright.dev/python/docs/network) — Playwright 代理配置官方文档 +- [tenacity Documentation](https://tenacity.readthedocs.io/) — Python 重试库官方文档(现有依赖) + +### Secondary (MEDIUM confidence) +- [Oxylabs: How to Rotate Proxies in Python](https://oxylabs.io/blog/rotate-proxies-python) — 代理轮换策略概述 +- [Stack Overflow: Round Robin proxy rotation](https://stackoverflow.com/questions/74110824/how-to-make-different-session-proxy-with-each-request) — 实现代码示例 + +### Tertiary (LOW confidence) +- [DEV.to: Proxy Rotation for Web Scraping in 2026](https://dev.to/agenthustler/proxy-rotation-for-web-scraping-in-2026-the-complete-guide-with-code-23d) — 通用指南(需验证) + +### Code Analysis (HIGH confidence) +- `kb_workflow/crawler/config.py` — 现有代理配置实现 +- `kb_workflow/crawler/zhihu_crawler.py` — 现有爬虫实现 +- `.planning/research/PITFALLS.md` — 反爬虫陷阱研究 +- `.planning/research/STACK.md` — 技术栈研究 + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — 基于现有项目依赖和官方文档 +- Architecture: HIGH — 基于现有代码模式和最佳实践 +- Pitfalls: HIGH — 基于项目 PITFALLS.md 和社区经验 + +**Research date:** 2026-03-18 +**Valid until:** 30 days (稳定技术栈)