docs(10): research proxy pool domain

This commit is contained in:
2026-03-18 16:18:38 +08:00
parent 1b58bf9364
commit fe22f94358
@@ -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>
## 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 自动获取代理
- **加权轮换** — 根据代理质量/速度加权
- **代理统计面板** — 展示每个代理的成功率、响应时间
- **配置热加载** — 运行时检测配置文件变化并重新加载
</user_constraints>
<phase_requirements>
## Phase Requirements
| ID | Description | Research Support |
|----|-------------|-----------------|
| PROXY-01 | 用户可以配置代理池 — 支持从文件或环境变量加载多个代理地址 | YAML 配置 + Pydantic BaseModel 解析 + 向后兼容环境变量 |
| PROXY-02 | 系统自动轮换代理 — 每次请求自动选择下一个可用代理 | itertools.cycle + 索引指针实现 Round-Robin |
| PROXY-03 | 代理健康检查 — 检测代理可用性,自动移除失效代理 | 被动检测(失败计数)+ 连续3次失败标记失效 |
| PROXY-04 | 代理失败回退 — 当前代理失败时自动切换到下一个代理 | 失效代理跳过 + 冷却恢复 + 全失效回退策略 |
</phase_requirements>
## 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 (稳定技术栈)