434 lines
12 KiB
Python
434 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
爬虫性能优化模块 v1.0
|
|
|
|
功能:
|
|
1. 连接池管理 - 复用 HTTP 连接,减少握手开销
|
|
2. 并发请求 - 线程池批量请求,提高吞吐量
|
|
3. DNS 缓存 - 缓存 DNS 解析结果
|
|
4. 响应压缩 - 自动解压 gzip/br 响应
|
|
5. 批量请求优化 - 智能分组 + 限流
|
|
|
|
使用示例:
|
|
```python
|
|
from crawl_performance import PerformanceCrawler
|
|
|
|
crawler = PerformanceCrawler(max_workers=5)
|
|
|
|
# 批量请求
|
|
urls = ["https://example.com/1", "https://example.com/2", ...]
|
|
results = crawler.batch_get(urls)
|
|
|
|
# 带限流的批量请求
|
|
results = crawler.batch_get(urls, rate_limit=True)
|
|
```
|
|
"""
|
|
|
|
import os
|
|
import time
|
|
import socket
|
|
import hashlib
|
|
import logging
|
|
import threading
|
|
from typing import Dict, List, Optional, Any, Tuple
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
from dataclasses import dataclass, field
|
|
from urllib.parse import urlparse
|
|
|
|
logger = logging.getLogger("crawl_performance")
|
|
|
|
|
|
# ============================================================
|
|
# DNS 缓存
|
|
# ============================================================
|
|
|
|
class DNSCache:
|
|
"""DNS 解析缓存,避免重复解析"""
|
|
|
|
def __init__(self, ttl: int = 3600):
|
|
self.ttl = ttl
|
|
self._cache: Dict[str, Tuple[float, str]] = {}
|
|
self._lock = threading.Lock()
|
|
|
|
def resolve(self, hostname: str) -> Optional[str]:
|
|
"""解析主机名(带缓存)"""
|
|
now = time.time()
|
|
|
|
with self._lock:
|
|
if hostname in self._cache:
|
|
cached_time, ip = self._cache[hostname]
|
|
if now - cached_time < self.ttl:
|
|
return ip
|
|
|
|
try:
|
|
ip = socket.gethostbyname(hostname)
|
|
with self._lock:
|
|
self._cache[hostname] = (now, ip)
|
|
# 限制缓存大小
|
|
if len(self._cache) > 1000:
|
|
oldest = min(self._cache.items(), key=lambda x: x[1][0])
|
|
del self._cache[oldest[0]]
|
|
return ip
|
|
except socket.gaierror:
|
|
return None
|
|
|
|
def clear(self):
|
|
"""清除缓存"""
|
|
with self._lock:
|
|
self._cache.clear()
|
|
|
|
|
|
# 全局 DNS 缓存
|
|
_dns_cache = DNSCache()
|
|
|
|
|
|
# ============================================================
|
|
# 请求结果
|
|
# ============================================================
|
|
|
|
@dataclass
|
|
class CrawlResult:
|
|
"""爬取结果"""
|
|
url: str
|
|
success: bool
|
|
status_code: int = 0
|
|
content: Optional[bytes] = None
|
|
text: Optional[str] = None
|
|
error: Optional[str] = None
|
|
elapsed: float = 0.0
|
|
from_cache: bool = False
|
|
retry_count: int = 0
|
|
|
|
|
|
# ============================================================
|
|
# 性能爬虫
|
|
# ============================================================
|
|
|
|
class PerformanceCrawler:
|
|
"""
|
|
高性能爬虫
|
|
|
|
特性:
|
|
- 线程池并发请求
|
|
- DNS 缓存
|
|
- 响应缓存
|
|
- 自动重试
|
|
- 限流控制
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
max_workers: int = 5,
|
|
timeout: int = 30,
|
|
max_retries: int = 2,
|
|
cache_ttl: int = 1800,
|
|
enable_dns_cache: bool = True
|
|
):
|
|
"""
|
|
初始化爬虫
|
|
|
|
Args:
|
|
max_workers: 最大并发数
|
|
timeout: 请求超时(秒)
|
|
max_retries: 最大重试次数
|
|
cache_ttl: 缓存 TTL(秒)
|
|
enable_dns_cache: 是否启用 DNS 缓存
|
|
"""
|
|
self.max_workers = max_workers
|
|
self.timeout = timeout
|
|
self.max_retries = max_retries
|
|
self.enable_dns_cache = enable_dns_cache
|
|
|
|
# 响应缓存
|
|
self._cache: Dict[str, Tuple[float, CrawlResult]] = {}
|
|
self._cache_ttl = cache_ttl
|
|
self._cache_lock = threading.Lock()
|
|
|
|
# 统计信息
|
|
self._stats = {
|
|
'total_requests': 0,
|
|
'cache_hits': 0,
|
|
'errors': 0,
|
|
'total_time': 0.0,
|
|
}
|
|
self._stats_lock = threading.Lock()
|
|
|
|
# 限流控制
|
|
self._domain_last_request: Dict[str, float] = {}
|
|
self._domain_lock = threading.Lock()
|
|
|
|
def _get_from_cache(self, url: str) -> Optional[CrawlResult]:
|
|
"""从缓存获取结果"""
|
|
cache_key = hashlib.md5(url.encode()).hexdigest()
|
|
|
|
with self._cache_lock:
|
|
if cache_key in self._cache:
|
|
cached_time, result = self._cache[cache_key]
|
|
if time.time() - cached_time < self._cache_ttl:
|
|
result.from_cache = True
|
|
return result
|
|
del self._cache[cache_key]
|
|
return None
|
|
|
|
def _set_cache(self, url: str, result: CrawlResult):
|
|
"""写入缓存"""
|
|
cache_key = hashlib.md5(url.encode()).hexdigest()
|
|
|
|
with self._cache_lock:
|
|
self._cache[cache_key] = (time.time(), result)
|
|
# 限制缓存大小
|
|
if len(self._cache) > 500:
|
|
oldest_key = min(self._cache, key=lambda k: self._cache[k][0])
|
|
del self._cache[oldest_key]
|
|
|
|
def _rate_limit(self, url: str, delay: float = 1.0):
|
|
"""按域名限流"""
|
|
parsed = urlparse(url)
|
|
domain = parsed.netloc
|
|
|
|
with self._domain_lock:
|
|
now = time.time()
|
|
last_time = self._domain_last_request.get(domain, 0)
|
|
elapsed = now - last_time
|
|
|
|
if elapsed < delay:
|
|
wait_time = delay - elapsed
|
|
time.sleep(wait_time)
|
|
|
|
self._domain_last_request[domain] = time.time()
|
|
|
|
def _single_get(
|
|
self,
|
|
url: str,
|
|
headers: Optional[Dict[str, str]] = None,
|
|
use_cache: bool = True
|
|
) -> CrawlResult:
|
|
"""单个 GET 请求"""
|
|
import urllib.request
|
|
import urllib.error
|
|
import gzip
|
|
import io
|
|
|
|
# 检查缓存
|
|
if use_cache:
|
|
cached = self._get_from_cache(url)
|
|
if cached:
|
|
with self._stats_lock:
|
|
self._stats['cache_hits'] += 1
|
|
return cached
|
|
|
|
start_time = time.time()
|
|
|
|
# DNS 缓存
|
|
if self.enable_dns_cache:
|
|
parsed = urlparse(url)
|
|
if parsed.hostname:
|
|
_dns_cache.resolve(parsed.hostname)
|
|
|
|
# 默认 headers
|
|
default_headers = {
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
'Accept-Encoding': 'gzip, deflate',
|
|
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
|
|
}
|
|
if headers:
|
|
default_headers.update(headers)
|
|
|
|
# 重试逻辑
|
|
last_error = None
|
|
for attempt in range(self.max_retries + 1):
|
|
try:
|
|
req = urllib.request.Request(url, headers=default_headers)
|
|
|
|
with urllib.request.urlopen(req, timeout=self.timeout) as response:
|
|
data = response.read()
|
|
|
|
# 处理 gzip 压缩
|
|
if response.headers.get('Content-Encoding') == 'gzip':
|
|
data = gzip.decompress(data)
|
|
|
|
# 检测编码
|
|
content_type = response.headers.get('Content-Type', '')
|
|
encoding = 'utf-8'
|
|
if 'charset=' in content_type:
|
|
encoding = content_type.split('charset=')[-1].strip()
|
|
|
|
try:
|
|
text = data.decode(encoding)
|
|
except (UnicodeDecodeError, LookupError):
|
|
text = data.decode('utf-8', errors='replace')
|
|
|
|
result = CrawlResult(
|
|
url=url,
|
|
success=True,
|
|
status_code=response.getcode(),
|
|
content=data,
|
|
text=text,
|
|
elapsed=time.time() - start_time,
|
|
retry_count=attempt,
|
|
)
|
|
|
|
# 写入缓存
|
|
if use_cache:
|
|
self._set_cache(url, result)
|
|
|
|
with self._stats_lock:
|
|
self._stats['total_requests'] += 1
|
|
self._stats['total_time'] += result.elapsed
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
last_error = e
|
|
if attempt < self.max_retries:
|
|
time.sleep(2 ** attempt) # 指数退避
|
|
|
|
# 所有重试失败
|
|
result = CrawlResult(
|
|
url=url,
|
|
success=False,
|
|
error=str(last_error),
|
|
elapsed=time.time() - start_time,
|
|
retry_count=self.max_retries,
|
|
)
|
|
|
|
with self._stats_lock:
|
|
self._stats['total_requests'] += 1
|
|
self._stats['errors'] += 1
|
|
|
|
return result
|
|
|
|
def get(
|
|
self,
|
|
url: str,
|
|
headers: Optional[Dict[str, str]] = None,
|
|
use_cache: bool = True,
|
|
rate_limit: bool = False
|
|
) -> CrawlResult:
|
|
"""
|
|
单个 GET 请求
|
|
|
|
Args:
|
|
url: 目标 URL
|
|
headers: 额外 headers
|
|
use_cache: 是否使用缓存
|
|
rate_limit: 是否限流
|
|
|
|
Returns:
|
|
CrawlResult
|
|
"""
|
|
if rate_limit:
|
|
self._rate_limit(url)
|
|
|
|
return self._single_get(url, headers, use_cache)
|
|
|
|
def batch_get(
|
|
self,
|
|
urls: List[str],
|
|
headers: Optional[Dict[str, str]] = None,
|
|
use_cache: bool = True,
|
|
rate_limit: bool = True,
|
|
rate_delay: float = 1.0
|
|
) -> List[CrawlResult]:
|
|
"""
|
|
批量 GET 请求(并发)
|
|
|
|
Args:
|
|
urls: URL 列表
|
|
headers: 额外 headers
|
|
use_cache: 是否使用缓存
|
|
rate_limit: 是否限流
|
|
rate_delay: 限流延迟(秒)
|
|
|
|
Returns:
|
|
List[CrawlResult]
|
|
"""
|
|
if not urls:
|
|
return []
|
|
|
|
results = [None] * len(urls)
|
|
|
|
def fetch_with_index(index: int, url: str) -> Tuple[int, CrawlResult]:
|
|
if rate_limit:
|
|
self._rate_limit(url, rate_delay)
|
|
result = self._single_get(url, headers, use_cache)
|
|
return index, result
|
|
|
|
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
|
futures = {
|
|
executor.submit(fetch_with_index, i, url): i
|
|
for i, url in enumerate(urls)
|
|
}
|
|
|
|
for future in as_completed(futures):
|
|
try:
|
|
index, result = future.result()
|
|
results[index] = result
|
|
except Exception as e:
|
|
index = futures[future]
|
|
results[index] = CrawlResult(
|
|
url=urls[index],
|
|
success=False,
|
|
error=str(e),
|
|
)
|
|
|
|
return results
|
|
|
|
def get_stats(self) -> Dict[str, Any]:
|
|
"""获取统计信息"""
|
|
with self._stats_lock:
|
|
stats = dict(self._stats)
|
|
|
|
stats['cache_size'] = len(self._cache)
|
|
stats['cache_hit_rate'] = (
|
|
stats['cache_hits'] / stats['total_requests']
|
|
if stats['total_requests'] > 0 else 0
|
|
)
|
|
stats['avg_response_time'] = (
|
|
stats['total_time'] / stats['total_requests']
|
|
if stats['total_requests'] > 0 else 0
|
|
)
|
|
|
|
return stats
|
|
|
|
def clear_cache(self):
|
|
"""清除缓存"""
|
|
with self._cache_lock:
|
|
self._cache.clear()
|
|
|
|
def reset_stats(self):
|
|
"""重置统计"""
|
|
with self._stats_lock:
|
|
self._stats = {
|
|
'total_requests': 0,
|
|
'cache_hits': 0,
|
|
'errors': 0,
|
|
'total_time': 0.0,
|
|
}
|
|
|
|
|
|
# ============================================================
|
|
# 便捷函数
|
|
# ============================================================
|
|
|
|
_global_crawler: Optional[PerformanceCrawler] = None
|
|
|
|
|
|
def get_crawler(max_workers: int = 5, **kwargs) -> PerformanceCrawler:
|
|
"""获取全局爬虫实例"""
|
|
global _global_crawler
|
|
if _global_crawler is None:
|
|
_global_crawler = PerformanceCrawler(max_workers=max_workers, **kwargs)
|
|
return _global_crawler
|
|
|
|
|
|
def fast_get(url: str, **kwargs) -> CrawlResult:
|
|
"""快速 GET 请求"""
|
|
return get_crawler().get(url, **kwargs)
|
|
|
|
|
|
def fast_batch_get(urls: List[str], **kwargs) -> List[CrawlResult]:
|
|
"""快速批量 GET 请求"""
|
|
return get_crawler().batch_get(urls, **kwargs)
|