Spaces:
Paused
Paused
| # /// zerodep | |
| # version = "0.1.0" | |
| # deps = [] | |
| # tier = "simple" | |
| # category = "network" | |
| # note = "Install/update via `zerodep add useragent`" | |
| # /// | |
| """Lightweight User-Agent generator for Chrome/Edge with Client Hints. | |
| Part of zerodep: https://github.com/Oaklight/zerodep | |
| Copyright (c) 2026 Peng Ding. MIT License. | |
| Generates realistic browser UA strings and matching ``Sec-CH-UA-*`` headers. | |
| Covers Windows, macOS, Linux (desktop) and Android (mobile) platforms with | |
| Chrome and Edge browsers only -- this is intentionally minimal. | |
| Inspired by `ua-generator <https://github.com/iamdual/ua-generator>`_ (Apache-2.0). | |
| Zero external dependencies -- stdlib ``random`` only. | |
| Basic usage:: | |
| from useragent import generate | |
| ua = generate(browser="chrome", device="desktop") | |
| print(ua.text) # full User-Agent string | |
| print(ua.headers.get()) # dict with UA + Client Hints headers | |
| """ | |
| from __future__ import annotations | |
| import random | |
| from typing import Sequence, Union | |
| __all__ = ["UserAgent", "generate"] | |
| __version__ = "0.1.0" | |
| # --------------------------------------------------------------------------- | |
| # Version data | |
| # --------------------------------------------------------------------------- | |
| # (major, build) pairs. Patch is randomised at generation time. | |
| # Sources: | |
| # Chrome -- https://chromereleases.googleblog.com/search/label/Stable%20updates | |
| # Edge -- https://learn.microsoft.com/en-us/deployedge/microsoft-edge-release-schedule | |
| _CHROME_VERSIONS: list[tuple[int, int]] = [ | |
| (120, 6099), | |
| (121, 6167), | |
| (122, 6261), | |
| (123, 6312), | |
| (124, 6367), | |
| (125, 6422), | |
| (126, 6478), | |
| (127, 6533), | |
| (128, 6613), | |
| (129, 6668), | |
| (130, 6723), | |
| (131, 6778), | |
| (132, 6834), | |
| (133, 6943), | |
| (134, 6998), | |
| (135, 7049), | |
| (136, 7103), | |
| (137, 7151), | |
| (138, 7204), | |
| (139, 7258), | |
| (140, 7339), | |
| (141, 7390), | |
| (142, 7444), | |
| (143, 7499), | |
| (144, 7559), | |
| (145, 7632), | |
| (146, 7680), | |
| ] | |
| _EDGE_VERSIONS: list[tuple[int, int]] = [ | |
| (120, 2210), | |
| (121, 2277), | |
| (122, 2365), | |
| (123, 2420), | |
| (124, 2478), | |
| (125, 2535), | |
| (126, 2592), | |
| (127, 2651), | |
| (128, 2739), | |
| (129, 2792), | |
| (130, 2849), | |
| (131, 2903), | |
| (132, 2957), | |
| (133, 3065), | |
| (134, 3124), | |
| (135, 3179), | |
| (136, 3240), | |
| (137, 3296), | |
| (138, 3351), | |
| (139, 3405), | |
| (140, 3485), | |
| (141, 3537), | |
| (142, 3595), | |
| (143, 3650), | |
| (144, 3719), | |
| (145, 3800), | |
| (146, 3856), | |
| ] | |
| # Windows NT versions: (nt_major, nt_minor, ch_platform_range) | |
| _WINDOWS_VERSIONS: list[tuple[float, tuple[int, int]]] = [ | |
| (10.0, (1, 10)), # Windows 10 | |
| (10.0, (13, 16)), # Windows 11 | |
| ] | |
| # macOS versions: (major, minor, max_build) | |
| _MACOS_VERSIONS: list[tuple[int, int, int]] = [ | |
| (13, 6, 9), | |
| (13, 7, 8), | |
| (14, 4, 1), | |
| (14, 5, 0), | |
| (14, 6, 1), | |
| (14, 7, 8), | |
| (14, 8, 4), | |
| (15, 0, 1), | |
| (15, 1, 1), | |
| (15, 2, 1), | |
| (15, 3, 1), | |
| (15, 4, 1), | |
| (15, 5, 0), | |
| (15, 6, 1), | |
| (15, 7, 4), | |
| (26, 0, 1), | |
| (26, 1, 0), | |
| (26, 2, 0), | |
| (26, 3, 2), | |
| ] | |
| # Android model strings (Samsung subset -- most common vendor) | |
| _ANDROID_MODELS: tuple[str, ...] = ( | |
| "SM-G991B", | |
| "SM-G996B", | |
| "SM-G998B", | |
| "SM-A526B", | |
| "SM-A536B", | |
| "SM-S901B", | |
| "SM-S906B", | |
| "SM-S908B", | |
| "SM-S911B", | |
| "SM-S916B", | |
| "SM-S918B", | |
| "SM-S921B", | |
| "SM-S926B", | |
| "SM-S928B", | |
| "SM-A546B", | |
| "SM-A556B", | |
| "SM-A346B", | |
| "SM-A256B", | |
| "SM-A156B", | |
| "SM-G781B", | |
| ) | |
| _DESKTOP_PLATFORMS = ("windows", "macos", "linux") | |
| _MOBILE_PLATFORMS = ("android",) | |
| _ALL_PLATFORMS = _DESKTOP_PLATFORMS + _MOBILE_PLATFORMS | |
| _WEBKIT = "537.36" | |
| # --------------------------------------------------------------------------- | |
| # Internal helpers | |
| # --------------------------------------------------------------------------- | |
| def _pick_chrome_version() -> tuple[int, int, int, int]: | |
| """Return (major, minor=0, build, patch).""" | |
| major, build = random.choice(_CHROME_VERSIONS) | |
| return (major, 0, build, random.randint(0, 255)) | |
| def _pick_edge_version() -> tuple[int, int, int, int]: | |
| major, build = random.choice(_EDGE_VERSIONS) | |
| return (major, 0, build, random.randint(0, 99)) | |
| def _fmt_ver(v: tuple[int, ...], n: int = 4, sep: str = ".") -> str: | |
| """Format version tuple to string, padding with 0s up to *n* parts.""" | |
| parts = list(v[:n]) | |
| while len(parts) < n: | |
| parts.append(0) | |
| return sep.join(str(p) for p in parts) | |
| def _pick_platform(device: str | None) -> str: | |
| if device == "desktop": | |
| return random.choice(_DESKTOP_PLATFORMS) | |
| if device == "mobile": | |
| return random.choice(_MOBILE_PLATFORMS) | |
| return random.choice(_ALL_PLATFORMS) | |
| def _build_ua_string( | |
| browser: str, | |
| platform: str, | |
| ver: tuple[int, int, int, int], | |
| ) -> str: | |
| """Build a User-Agent string from browser/platform/version.""" | |
| chrome_str = _fmt_ver(ver) | |
| if platform == "windows": | |
| nt = random.choice(_WINDOWS_VERSIONS) | |
| nt_str = f"{nt[0]:.1f}".replace(".0", ".0") # "10.0" | |
| base = ( | |
| f"Mozilla/5.0 (Windows NT {nt_str}; Win64; x64) " | |
| f"AppleWebKit/{_WEBKIT} (KHTML, like Gecko) " | |
| f"Chrome/{chrome_str} Safari/{_WEBKIT}" | |
| ) | |
| if browser == "edge": | |
| base += f" Edg/{chrome_str}" | |
| return base | |
| if platform == "linux": | |
| tpl = random.choice( | |
| ( | |
| f"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/{_WEBKIT} " | |
| f"(KHTML, like Gecko) Chrome/{chrome_str} Safari/{_WEBKIT}", | |
| f"Mozilla/5.0 (X11; Ubuntu; Linux x86_64) AppleWebKit/{_WEBKIT} " | |
| f"(KHTML, like Gecko) Chrome/{chrome_str} Safari/{_WEBKIT}", | |
| ) | |
| ) | |
| if browser == "edge": | |
| tpl += f" Edg/{chrome_str}" | |
| return tpl | |
| if platform == "macos": | |
| mv = random.choice(_MACOS_VERSIONS) | |
| build = random.randint(0, mv[2]) if mv[2] > 0 else 0 | |
| mac_str = f"{mv[0]}_{mv[1]}" | |
| if build: | |
| mac_str += f"_{build}" | |
| base = ( | |
| f"Mozilla/5.0 (Macintosh; Intel Mac OS X {mac_str}) " | |
| f"AppleWebKit/{_WEBKIT} (KHTML, like Gecko) " | |
| f"Chrome/{chrome_str} Safari/{_WEBKIT}" | |
| ) | |
| if browser == "edge": | |
| base += f" Edg/{chrome_str}" | |
| return base | |
| if platform == "android": | |
| android_ver = random.randint(12, 16) | |
| model = random.choice(_ANDROID_MODELS) | |
| base = ( | |
| f"Mozilla/5.0 (Linux; Android {android_ver}; {model}) " | |
| f"AppleWebKit/{_WEBKIT} (KHTML, like Gecko) " | |
| f"Chrome/{chrome_str} Mobile Safari/{_WEBKIT}" | |
| ) | |
| if browser == "edge": | |
| base += f" EdgA/{chrome_str}" | |
| return base | |
| raise ValueError(f"Unsupported platform: {platform}") | |
| # --------------------------------------------------------------------------- | |
| # Client Hints serialization | |
| # --------------------------------------------------------------------------- | |
| def _ch_bool(val: bool) -> str: | |
| return "?1" if val else "?0" | |
| def _ch_string(val: str) -> str: | |
| return f'"{val}"' | |
| def _ch_brand_list(brands: list[dict[str, str]]) -> str: | |
| return ", ".join(f'"{b["brand"]}";v="{b["version"]}"' for b in brands) | |
| # --------------------------------------------------------------------------- | |
| # Public API | |
| # --------------------------------------------------------------------------- | |
| class UserAgent: | |
| """A generated user-agent with matching Client Hints headers. | |
| Attributes: | |
| browser: Browser name (``"chrome"`` or ``"edge"``). | |
| platform: Platform name (``"windows"``, ``"macos"``, ``"linux"``, | |
| or ``"android"``). | |
| version: 4-tuple ``(major, minor, build, patch)``. | |
| text: The full User-Agent string. | |
| headers: A :class:`_Headers` instance for Client Hints. | |
| Example:: | |
| ua = generate(browser=["chrome", "edge"]) | |
| ua.headers.accept_ch("Sec-CH-UA-Platform-Version") | |
| headers = ua.headers.get() | |
| """ | |
| def __init__( | |
| self, | |
| *, | |
| browser: str, | |
| platform: str, | |
| version: tuple[int, int, int, int], | |
| ua_string: str, | |
| ) -> None: | |
| self.browser = browser | |
| self.platform = platform | |
| self.version = version | |
| self.text = ua_string | |
| self.headers = _Headers(self) | |
| # -- Client Hints data methods -- | |
| def _is_mobile(self) -> bool: | |
| return self.platform in _MOBILE_PLATFORMS | |
| def _ch_platform_name(self) -> str: | |
| mapping = { | |
| "windows": "Windows", | |
| "macos": "macOS", | |
| "linux": "Linux", | |
| "android": "Android", | |
| } | |
| return mapping.get(self.platform, self.platform.title()) | |
| def _ch_platform_version(self) -> str: | |
| if self.platform == "windows": | |
| lo, hi = random.choice(_WINDOWS_VERSIONS)[1] | |
| return f"{random.randint(lo, hi)}.0.0" | |
| if self.platform == "macos": | |
| mv = random.choice(_MACOS_VERSIONS) | |
| return f"{mv[0]}.{mv[1]}.{random.randint(0, max(mv[2], 1))}" | |
| if self.platform == "android": | |
| return f"{random.randint(12, 16)}.0.0" | |
| # Linux | |
| return "" | |
| def _ch_brands(self, full_version: bool = False) -> list[dict[str, str]]: | |
| ver_str = _fmt_ver(self.version) if full_version else str(self.version[0]) | |
| filler_ver = "99.0.0.0" if full_version else "99" | |
| brands: list[dict[str, str]] = [{"brand": "Not A(Brand", "version": filler_ver}] | |
| if self.browser == "chrome": | |
| brands.append({"brand": "Chromium", "version": ver_str}) | |
| brands.append({"brand": "Google Chrome", "version": ver_str}) | |
| elif self.browser == "edge": | |
| brands.append({"brand": "Chromium", "version": ver_str}) | |
| brands.append({"brand": "Microsoft Edge", "version": ver_str}) | |
| return brands | |
| def _ch_architecture(self) -> str: | |
| if self.platform in ("android",): | |
| return "arm" | |
| if self.platform == "macos": | |
| return random.choice(("arm", "x86", "arm", "arm")) | |
| return "x86" | |
| def _ch_bitness(self) -> str: | |
| if self.platform == "android": | |
| return random.choice(("32", "64", "32", "32")) | |
| return "64" | |
| def _ch_model(self) -> str: | |
| if self.platform == "android": | |
| return random.choice(_ANDROID_MODELS) | |
| return "" | |
| def __str__(self) -> str: | |
| return self.text | |
| def __repr__(self) -> str: | |
| return f"UserAgent(browser={self.browser!r}, platform={self.platform!r})" | |
| class _Headers: | |
| """Lazy header builder matching the ``ua_generator.Headers`` interface.""" | |
| def __init__(self, ua: UserAgent) -> None: | |
| self._ua = ua | |
| self._headers: dict[str, str] = {} | |
| self._initialised = False | |
| def _reset(self) -> None: | |
| self._initialised = True | |
| self._headers = {"user-agent": self._ua.text} | |
| # Low-entropy hints (always included for Chromium) | |
| self._add("sec-ch-ua") | |
| self._add("sec-ch-ua-mobile") | |
| self._add("sec-ch-ua-platform") | |
| def _add(self, key: str) -> None: | |
| ua = self._ua | |
| if key == "sec-ch-ua": | |
| self._headers[key] = _ch_brand_list(ua._ch_brands(full_version=False)) | |
| elif key == "sec-ch-ua-full-version-list": | |
| self._headers[key] = _ch_brand_list(ua._ch_brands(full_version=True)) | |
| elif key == "sec-ch-ua-platform": | |
| self._headers[key] = _ch_string(ua._ch_platform_name()) | |
| elif key == "sec-ch-ua-platform-version": | |
| self._headers[key] = _ch_string(ua._ch_platform_version()) | |
| elif key == "sec-ch-ua-mobile": | |
| self._headers[key] = _ch_bool(ua._is_mobile()) | |
| elif key == "sec-ch-ua-arch": | |
| self._headers[key] = _ch_string(ua._ch_architecture()) | |
| elif key == "sec-ch-ua-bitness": | |
| self._headers[key] = _ch_string(ua._ch_bitness()) | |
| elif key == "sec-ch-ua-model": | |
| self._headers[key] = _ch_string(ua._ch_model()) | |
| def accept_ch(self, val: str) -> None: | |
| """Process an ``Accept-CH`` header value and populate matching hints. | |
| Args: | |
| val: Comma-separated list of hint names | |
| (e.g. ``"Sec-CH-UA-Platform-Version, Sec-CH-UA-Arch"``). | |
| """ | |
| self._reset() | |
| for hint in val.split(","): | |
| self._add(hint.strip().lower()) | |
| def get(self) -> dict[str, str]: | |
| """Return all generated headers as a dict.""" | |
| if not self._initialised: | |
| self._reset() | |
| return self._headers | |
| def generate( | |
| *, | |
| browser: Union[str, Sequence[str]] | None = None, | |
| device: str | None = None, | |
| ) -> UserAgent: | |
| """Generate a random User-Agent with matching Client Hints. | |
| Args: | |
| browser: Browser name or list of names to pick from. | |
| Supported: ``"chrome"``, ``"edge"``. Defaults to both. | |
| device: Device type -- ``"desktop"`` or ``"mobile"``. | |
| Defaults to random selection across all platforms. | |
| Returns: | |
| A :class:`UserAgent` instance. | |
| Raises: | |
| ValueError: If *browser* is not ``"chrome"`` or ``"edge"``. | |
| """ | |
| # Resolve browser | |
| if browser is None: | |
| chosen_browser = random.choice(("chrome", "edge")) | |
| elif isinstance(browser, str): | |
| chosen_browser = browser | |
| else: | |
| chosen_browser = random.choice(list(browser)) | |
| if chosen_browser not in ("chrome", "edge"): | |
| raise ValueError(f"Unsupported browser: {chosen_browser!r}") | |
| # Resolve platform | |
| platform = _pick_platform(device) | |
| # Pick version | |
| if chosen_browser == "chrome": | |
| ver = _pick_chrome_version() | |
| else: | |
| ver = _pick_edge_version() | |
| ua_string = _build_ua_string(chosen_browser, platform, ver) | |
| return UserAgent( | |
| browser=chosen_browser, platform=platform, version=ver, ua_string=ua_string | |
| ) | |