Source code for pyfetcher.headers.rotating

"""Rotating header provider for :mod:`pyfetcher`.

Purpose:
    Provide a header provider that rotates through browser profiles to
    distribute requests across multiple browser identities, reducing the
    likelihood of fingerprint-based blocking.

Design:
    - Rotation happens at the session level: a profile is selected per
      call and all headers for that request are internally consistent.
    - Profile selection uses market-share weights by default but can be
      configured with an explicit profile list.
    - The provider maintains no mutable state beyond the configuration,
      so it is safe to share across threads/tasks.

Examples:
    ::

        >>> provider = RotatingHeaderProvider()
        >>> headers = provider.build(request=FetchRequest(url="https://example.com"))
        >>> "user-agent" in headers
        True
"""

from __future__ import annotations

import random

from pyfetcher.contracts.request import FetchRequest
from pyfetcher.headers.profiles import (
    DESKTOP_PROFILES,
    PROFILE_WEIGHTS,
    BrowserProfile,
)


[docs] class RotatingHeaderProvider: """Header provider that rotates through browser profiles. Each call to :meth:`build` selects a random profile from the configured pool (weighted by market share) and returns a complete, internally consistent set of headers for that browser identity. Args: profiles: Optional list of profiles to rotate through. Defaults to all desktop profiles. weights: Optional list of weights for profile selection. Must have the same length as ``profiles``. Defaults to market-share weights. Examples: :: >>> provider = RotatingHeaderProvider() >>> headers = provider.build(request=FetchRequest(url="https://example.com")) >>> "user-agent" in headers True """ def __init__( self, profiles: list[BrowserProfile] | None = None, weights: list[float] | None = None, ) -> None: self._profiles = profiles or list(DESKTOP_PROFILES) self._weights = weights or [PROFILE_WEIGHTS.get(p.name, 0.01) for p in self._profiles] @property def profiles(self) -> list[BrowserProfile]: """Return the configured profile pool. Returns: The list of profiles available for rotation. """ return list(self._profiles) def _select_profile(self) -> BrowserProfile: """Select a random profile using configured weights. Returns: A randomly selected :class:`BrowserProfile`. """ selected = random.choices(self._profiles, weights=self._weights, k=1) # nosec B311 return selected[0]
[docs] def build(self, *, request: FetchRequest) -> dict[str, str]: """Build headers using a randomly selected browser profile. Selects a profile, generates its full header set with randomized variation, then merges per-request headers on top. Args: request: The fetch request for which to build headers. Returns: A complete headers dictionary from the selected profile. Examples: :: >>> provider = RotatingHeaderProvider() >>> headers = provider.build(request=FetchRequest(url="https://example.com")) >>> "accept" in headers True """ profile = self._select_profile() headers = profile.to_headers() headers["accept-language"] = random.choice(profile.accept_language_options) # noqa: S311 # nosec B311 if profile.browser in ("chrome", "edge"): headers["sec-ch-prefers-color-scheme"] = random.choice( # noqa: S311 # nosec B311 ["light", "dark", "no-preference"] ) headers.update(request.headers) return headers