Source code for silkworm._middlewares.proxy

from __future__ import annotations

import random
from pathlib import Path
from typing import TYPE_CHECKING

from .._types import JSONValue
from ..logging import Logger, get_logger
from ..request import Request

if TYPE_CHECKING:
    from collections.abc import Iterable, Sequence

    from ..spiders import Spider


[docs] class ProxyMiddleware: """Assign proxies to requests and rotate away from failed proxies. Args: proxies: Proxy URLs loaded directly. proxy_file: UTF-8 file containing one proxy URL per non-empty line. random_selection: Choose randomly instead of round-robin. Exactly one proxy source is required. A pre-existing string in ``request.meta["proxy"]`` takes precedence. Register the same instance as exception middleware to retry failures through unused proxies. """ _FAILED_PROXIES_META_KEY = "_proxy_failed_proxies" _PROXY_RETRY_TIMES_META_KEY = "_proxy_retry_times"
[docs] def __init__( self, proxies: Iterable[str] | None = None, proxy_file: str | Path | None = None, random_selection: bool = False, ) -> None: if proxies is not None and proxy_file is not None: msg = ( "Cannot specify both 'proxies' and 'proxy_file'. Use one or the other." ) raise ValueError(msg) if proxies is None and proxy_file is None: msg = "Must provide either 'proxies' (iterable) or 'proxy_file' (path)." raise ValueError(msg) if proxy_file is not None: proxy_path = Path(proxy_file) if not proxy_path.exists(): msg = f"Proxy file not found: {proxy_file}" raise FileNotFoundError(msg) with proxy_path.open("r", encoding="utf-8") as f: self.proxies = [line.strip() for line in f if line.strip()] else: # At this point, proxies is guaranteed to be not None due to the check above assert proxies is not None self.proxies: list[str] = list(proxies) if not self.proxies: msg = "ProxyMiddleware requires at least one proxy." raise ValueError(msg) self.random_selection = random_selection self._idx = 0 self.logger: Logger = get_logger(component="ProxyMiddleware")
[docs] async def process_request(self, request: Request, spider: Spider) -> Request: """Preserve an explicit proxy or assign the next configured proxy.""" proxy = request.meta.get("proxy") if isinstance(proxy, str): self.logger.debug("Using existing proxy", proxy=proxy, url=request.url) return request proxy = self._select_proxy() if proxy is None: return request request.meta.setdefault("proxy", proxy) self.logger.debug("Assigned proxy", proxy=proxy, url=request.url) return request
[docs] async def process_exception( self, request: Request, exception: Exception, spider: Spider, ) -> Request | None: """Retry with an unused proxy, or return ``None`` when none remain.""" current_proxy = request.meta.get("proxy") if not isinstance(current_proxy, str): return None failed_proxies = self._get_failed_proxies(request) failed_proxies.append(current_proxy) next_proxy = self._select_proxy(excluded=failed_proxies) if next_proxy is None: self.logger.warning( "No proxy left to retry failed request", url=request.url, error=str(exception), error_type=exception.__class__.__name__, failed_proxies=failed_proxies, ) return None retry_request = request.replace( meta={**request.meta}, dont_filter=True, ) retry_request.meta["proxy"] = next_proxy failed_proxies_meta: list[JSONValue] = [proxy for proxy in failed_proxies] retry_request.meta[self._FAILED_PROXIES_META_KEY] = failed_proxies_meta retry_raw = retry_request.meta.get(self._PROXY_RETRY_TIMES_META_KEY, 0) retry_times = retry_raw if isinstance(retry_raw, int) else 0 retry_request.meta[self._PROXY_RETRY_TIMES_META_KEY] = retry_times + 1 self.logger.warning( "Retrying failed request with another proxy", url=request.url, error=str(exception), error_type=exception.__class__.__name__, old_proxy=current_proxy, new_proxy=next_proxy, attempt=retry_times + 1, ) return retry_request
def _select_proxy(self, *, excluded: Sequence[str] = ()) -> str | None: available_proxies = [proxy for proxy in self.proxies if proxy not in excluded] if not available_proxies: return None if self.random_selection: return random.choice(available_proxies) for offset in range(len(self.proxies)): candidate_idx = (self._idx + offset) % len(self.proxies) proxy = self.proxies[candidate_idx] if proxy in excluded: continue self._idx = (candidate_idx + 1) % len(self.proxies) return proxy return None def _get_failed_proxies(self, request: Request) -> list[str]: failed_raw = request.meta.get(self._FAILED_PROXIES_META_KEY, []) if not isinstance(failed_raw, list): return [] return [proxy for proxy in failed_raw if isinstance(proxy, str)]