feat(10-02): integrate ProxyPool into ZhihuCrawler

- Add proxy_pool parameter to ZhihuCrawler constructor
- Add _current_proxy tracking for health check feedback
- Implement _parse_proxy_url static method for URL parsing
- Implement _on_request_success/failure for proxy health feedback
- Prioritize proxy_pool over config.proxy for multi-proxy support
- Maintain backward compatibility with single proxy config

Tests:
- test_crawler_accepts_proxy_pool
- test_crawler_without_proxy_pool (backward compatibility)
- test_parse_proxy_url (HTTP, auth, SOCKS5)
- test_proxy_pool_takes_priority_over_config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 16:48:48 +08:00
parent 36a45095c1
commit b66bcc08b1
2 changed files with 122 additions and 4 deletions
+52 -3
View File
@@ -8,12 +8,14 @@ Supports:
- Random request delay - Random request delay
- Retry with exponential backoff - Retry with exponential backoff
- Automatic x-zse-96 signature handling via browser - Automatic x-zse-96 signature handling via browser
- ProxyPool integration for multi-proxy rotation
""" """
import random import random
import re import re
import time import time
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from urllib.parse import urlparse
from playwright.sync_api import Route, Response, sync_playwright from playwright.sync_api import Route, Response, sync_playwright
from tenacity import Retrying, stop_after_attempt, wait_exponential, RetryError from tenacity import Retrying, stop_after_attempt, wait_exponential, RetryError
@@ -32,6 +34,7 @@ from .models import (
CrawlResult, CrawlResult,
) )
from .config import CrawlerConfig, get_crawler_config from .config import CrawlerConfig, get_crawler_config
from .proxy_pool import ProxyPool, ProxyEntry
class ZhihuApiInterceptor: class ZhihuApiInterceptor:
@@ -104,7 +107,8 @@ class ZhihuCrawler:
self, self,
headless: bool = True, headless: bool = True,
timeout: int = 30, timeout: int = 30,
config: Optional[CrawlerConfig] = None config: Optional[CrawlerConfig] = None,
proxy_pool: Optional[ProxyPool] = None
): ):
"""Initialize ZhihuCrawler. """Initialize ZhihuCrawler.
@@ -112,10 +116,13 @@ class ZhihuCrawler:
headless: Whether to run browser in headless mode headless: Whether to run browser in headless mode
timeout: Request timeout in seconds timeout: Request timeout in seconds
config: Optional CrawlerConfig instance (uses environment if not provided) config: Optional CrawlerConfig instance (uses environment if not provided)
proxy_pool: Optional ProxyPool instance for multi-proxy rotation
""" """
self.headless = headless self.headless = headless
self.timeout = timeout self.timeout = timeout
self.config = config or get_crawler_config() self.config = config or get_crawler_config()
self.proxy_pool = proxy_pool
self._current_proxy: Optional[ProxyEntry] = None
self.browser: Optional[PlaywrightBrowser] = None self.browser: Optional[PlaywrightBrowser] = None
self.interceptor: Optional[ZhihuApiInterceptor] = None self.interceptor: Optional[ZhihuApiInterceptor] = None
# Internal browser management for proxy support # Internal browser management for proxy support
@@ -148,6 +155,7 @@ class ZhihuCrawler:
"""Start browser with proxy configuration. """Start browser with proxy configuration.
Validates proxy if required and applies to browser context. Validates proxy if required and applies to browser context.
Prioritizes proxy_pool over config.proxy for multi-proxy support.
Args: Args:
cookie: Cookie string for authentication cookie: Cookie string for authentication
@@ -166,8 +174,15 @@ class ZhihuCrawler:
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' 'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
} }
# Apply proxy if configured # Prioritize proxy pool over single proxy config
if self.config.proxy: 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:
# Backward compatibility: use single proxy configuration
context_options["proxy"] = self.config.proxy.to_playwright_format() context_options["proxy"] = self.config.proxy.to_playwright_format()
# Block Service Worker to prevent interference with API interception # Block Service Worker to prevent interference with API interception
@@ -182,6 +197,40 @@ class ZhihuCrawler:
self._page = self._context.new_page() self._page = self._context.new_page()
self._page.set_default_timeout(self.timeout * 1000) self._page.set_default_timeout(self.timeout * 1000)
@staticmethod
def _parse_proxy_url(url: str) -> Dict[str, Any]:
"""Parse proxy URL into Playwright format.
Args:
url: Proxy URL (e.g., http://user:pass@proxy.example.com:8080)
Returns:
Dictionary with server, username, password for Playwright
"""
parsed = urlparse(url)
server = f"{parsed.scheme}://{parsed.hostname}"
if parsed.port:
server += f":{parsed.port}"
config: Dict[str, Any] = {"server": server}
if parsed.username:
config["username"] = parsed.username
if parsed.password:
config["password"] = parsed.password
return config
def _on_request_success(self) -> None:
"""Notify proxy pool of successful request."""
if self._current_proxy:
self._current_proxy.record_success()
def _on_request_failure(self) -> None:
"""Notify proxy pool of failed request."""
if self._current_proxy:
self._current_proxy.record_failure(
max_fail_count=self.proxy_pool.config.max_fail_count
if self.proxy_pool else 3
)
def _add_cookies(self, cookie_str: str) -> None: def _add_cookies(self, cookie_str: str) -> None:
"""Add cookies to browser context. """Add cookies to browser context.
+70 -1
View File
@@ -6,12 +6,14 @@ Tests cover:
- Round-robin proxy rotation - Round-robin proxy rotation
- Empty pool handling - Empty pool handling
- Single proxy handling - Single proxy handling
- Health check and cooldown recovery
- ZhihuCrawler integration with ProxyPool
""" """
import os import os
import pytest import pytest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch, MagicMock
# Import will fail until proxy_pool module is implemented (RED phase) # Import will fail until proxy_pool module is implemented (RED phase)
from kb_workflow.crawler.proxy_pool import ProxyPool, ProxyEntry, ProxyPoolConfig from kb_workflow.crawler.proxy_pool import ProxyPool, ProxyEntry, ProxyPoolConfig
@@ -304,3 +306,70 @@ class TestProxyPoolHealthCheck:
pool._entries[1].record_failure(max_fail_count=3) pool._entries[1].record_failure(max_fail_count=3)
assert pool.get_available_count() == 1 assert pool.get_available_count() == 1
class TestZhihuCrawlerProxyPoolIntegration:
"""Tests for ZhihuCrawler integration with ProxyPool."""
def test_crawler_accepts_proxy_pool(self):
"""Test that ZhihuCrawler accepts proxy_pool parameter."""
from kb_workflow.crawler.zhihu_crawler import ZhihuCrawler
config = ProxyPoolConfig(
proxies=["http://proxy-a:8080", "http://proxy-b:8080"]
)
pool = ProxyPool(config)
crawler = ZhihuCrawler(proxy_pool=pool)
assert crawler.proxy_pool is pool
assert crawler.proxy_pool.has_proxies()
def test_crawler_without_proxy_pool(self):
"""Test that ZhihuCrawler works without proxy_pool (backward compatibility)."""
from kb_workflow.crawler.zhihu_crawler import ZhihuCrawler
crawler = ZhihuCrawler()
assert crawler.proxy_pool is None
def test_parse_proxy_url(self):
"""Test _parse_proxy_url static method."""
from kb_workflow.crawler.zhihu_crawler import ZhihuCrawler
# Test HTTP proxy without auth
result = ZhihuCrawler._parse_proxy_url("http://127.0.0.1:7890")
assert result == {"server": "http://127.0.0.1:7890"}
# Test HTTP proxy with auth
result = ZhihuCrawler._parse_proxy_url("http://user:pass@proxy.example.com:8080")
assert result == {
"server": "http://proxy.example.com:8080",
"username": "user",
"password": "pass"
}
# Test SOCKS5 proxy
result = ZhihuCrawler._parse_proxy_url("socks5://127.0.0.1:1080")
assert result == {"server": "socks5://127.0.0.1:1080"}
def test_proxy_pool_takes_priority_over_config(self):
"""Test that proxy_pool takes priority over config.proxy."""
from kb_workflow.crawler.zhihu_crawler import ZhihuCrawler
from kb_workflow.crawler.config import CrawlerConfig, ProxyConfig
# Create config with single proxy
single_proxy_config = CrawlerConfig(
proxy=ProxyConfig(server="http://config-proxy:9999")
)
# Create proxy pool with different proxies
pool = ProxyPool(ProxyPoolConfig(
proxies=["http://pool-proxy:8080"]
))
crawler = ZhihuCrawler(config=single_proxy_config, proxy_pool=pool)
# Verify proxy_pool is set
assert crawler.proxy_pool is pool
assert crawler.config.proxy is not None