from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from ua_parser import parse as ua_parse # pip install ua-parser
[docs]
BrandList = List[Brand]
# ---------- утилиты ----------
def _coalesce(*vals: Any) -> Any:
"""
Возвращает первый «содержательный» элемент из vals
(не None, не пустую строку, не пустые список/словарь), иначе None.
"""
for v in vals:
if v not in (None, "", [], {}):
return v
return None
def _join_version(*parts: Optional[str]) -> Optional[str]:
"""
Склеивает части версии через точку, пропуская пустые/None/мусор вроде '0-0'.
"""
filtered: List[str] = []
for p in parts:
if p is None or p in ("", "0-0"):
continue
filtered.append(p)
return ".".join(filtered) if filtered else None
def _primary_brand(brands: Optional[BrandList]) -> Optional[Brand]:
if not brands:
return None
return next((b for b in brands if "Not=A?Brand" not in (b.get("brand") or "")), brands[0])
# ---------- Новые вложенные датаклассы ----------
@dataclass
[docs]
class Screen:
[docs]
width: Optional[int] = None
[docs]
height: Optional[int] = None
[docs]
availWidth: Optional[int] = None
[docs]
availHeight: Optional[int] = None
[docs]
colorDepth: Optional[int] = None
[docs]
pixelDepth: Optional[int] = None
@dataclass
[docs]
class WindowDetails:
[docs]
innerWidth: Optional[int] = None
[docs]
innerHeight: Optional[int] = None
[docs]
devicePixelRatio: Optional[float] = None
@dataclass
[docs]
class TouchSupport:
[docs]
maxTouchPoints: int = 0
[docs]
touchEvent: Optional[bool] = None
@dataclass
[docs]
class Battery:
[docs]
level: Optional[float] = None
[docs]
charging: Optional[bool] = None
# ---------- UserAgent ----------
@dataclass
[docs]
class UserAgent:
[docs]
raw: Optional[str] = None
[docs]
browser_name: Optional[str] = field(default=None, init=False)
[docs]
browser_version: Optional[str] = field(default=None, init=False)
[docs]
os_name: Optional[str] = field(default=None, init=False)
[docs]
os_version: Optional[str] = field(default=None, init=False)
[docs]
device_brand: Optional[str] = field(default=None, init=False)
[docs]
device_model: Optional[str] = field(default=None, init=False)
[docs]
device_type: Optional[str] = field(default=None, init=False) # 'mobile'|'tablet'|'desktop'
[docs]
engine: Optional[str] = field(default=None, init=False)
[docs]
def __post_init__(self) -> None:
s = self.raw or ""
r = ua_parse(s) # Result(user_agent=..., os=..., device=...)
# --- браузер ---
ua = getattr(r, "user_agent", None)
if ua is not None:
self.browser_name = ua.family or None
self.browser_version = _join_version(
getattr(ua, "major", None),
getattr(ua, "minor", None),
getattr(ua, "patch", None),
getattr(ua, "patch_minor", None),
)
# --- ОС ---
os = getattr(r, "os", None)
if os is not None:
self.os_name = os.family or None
self.os_version = _join_version(
getattr(os, "major", None),
getattr(os, "minor", None),
getattr(os, "patch", None),
getattr(os, "patch_minor", None),
)
# --- устройство ---
dev = getattr(r, "device", None)
if dev is not None:
self.device_brand = getattr(dev, "brand", None) or None
self.device_model = getattr(dev, "model", None) or None
# тип устройства (просто и без эвристик уровня ML)
low = s.lower()
if "tablet" in low or "ipad" in low:
self.device_type = "tablet"
elif "mobile" in low:
self.device_type = "mobile"
else:
self.device_type = "desktop"
# движок по явным признакам
if "gecko/" in low and "firefox/" in low:
self.engine = "Gecko"
elif "applewebkit/" in low and re.search(r"(chrome|crios|edg|opr|yabrowser)/", low):
self.engine = "Blink"
elif "applewebkit/" in low:
self.engine = "WebKit"
else:
self.engine = None
# ---------- UserAgentClientHints ----------
@dataclass
[docs]
class UserAgentClientHints:
# ожидаем структуру:
# {"low_entropy": {...}, "high_entropy": {...}} или {"supported": false}
[docs]
raw: Optional[Dict[str, Any]] = None
[docs]
supported: Optional[bool] = field(default=None, init=False)
[docs]
mobile: Optional[bool] = field(default=None, init=False)
[docs]
brands: Optional[BrandList] = field(default=None, init=False)
[docs]
full_version_list: Optional[BrandList] = field(default=None, init=False)
[docs]
ua_full_version: Optional[str] = field(default=None, init=False)
[docs]
architecture: Optional[str] = field(default=None, init=False)
[docs]
bitness: Optional[str] = field(default=None, init=False)
[docs]
model: Optional[str] = field(default=None, init=False)
# удобное: «основной» бренд (name+version)
[docs]
primary_brand_name: Optional[str] = field(default=None, init=False)
[docs]
primary_brand_version: Optional[str] = field(default=None, init=False)
[docs]
def __post_init__(self) -> None:
d: Dict[str, Any] = self.raw or {}
low: Dict[str, Any] = d.get("low_entropy") or {}
high: Dict[str, Any] = d.get("high_entropy") or {}
self.supported = False if d.get("supported") is False else (None if not d else True)
self.mobile = low.get("mobile", high.get("mobile"))
self.brands = (low.get("brands") or high.get("brands")) or None
self.full_version_list = high.get("fullVersionList") or None
self.ua_full_version = high.get("uaFullVersion") or None
self.architecture = high.get("architecture") or None
self.bitness = high.get("bitness") or None
self.model = (high.get("model") or "") or None
self.platform = high.get("platform") or None
self.platform_version = high.get("platformVersion") or None
if pb := _primary_brand(self.full_version_list or self.brands):
self.primary_brand_name = pb.get("brand") or None
self.primary_brand_version = _coalesce(self.ua_full_version, pb.get("version"))
# ---------- Fingerprint ----------
@dataclass
[docs]
class Fingerprint:
# сырые входы
[docs]
user_agent: Optional[str] = None
[docs]
user_agent_client_hints: Optional[Dict[str, Any]] = None
[docs]
vendor: Optional[str] = None
[docs]
languages: Optional[List[str]] = None
[docs]
timezone: Optional[str] = None
# новые сырые входы из JS
[docs]
screen: Optional[Dict[str, Any]] = None
[docs]
window: Optional[Dict[str, Any]] = None
[docs]
hardware_concurrency: Optional[int] = None
[docs]
device_memory: Optional[float] = None
[docs]
cookies_enabled: Optional[bool] = None
[docs]
local_storage: Optional[bool] = None
[docs]
session_storage: Optional[bool] = None
[docs]
do_not_track: Optional[str] = None
[docs]
touch_support: Optional[Dict[str, Any]] = None
[docs]
orientation: Optional[str] = None
[docs]
battery: Optional[Dict[str, Any]] = None
[docs]
canvas_fingerprint: Optional[str] = None
[docs]
webgl_fingerprint: Optional[str] = None
[docs]
audio_fingerprint: Optional[str] = None
[docs]
fonts: Optional[List[str]] = None
# итоговые поля (UACH имеет приоритет, затем UA)
[docs]
browser_name: Optional[str] = field(default=None, init=False)
[docs]
browser_version: Optional[str] = field(default=None, init=False)
[docs]
os_name: Optional[str] = field(default=None, init=False)
[docs]
os_version: Optional[str] = field(default=None, init=False)
[docs]
device_type: Optional[str] = field(default=None, init=False)
[docs]
engine: Optional[str] = field(default=None, init=False)
# структурированные новые
[docs]
screen_details: Optional[Screen] = field(default=None, init=False)
[docs]
window_details: Optional[WindowDetails] = field(default=None, init=False)
[docs]
touch_support_details: Optional[TouchSupport] = field(default=None, init=False)
[docs]
battery_details: Optional[Battery] = field(default=None, init=False)
[docs]
uach: Optional[UserAgentClientHints] = field(default=None, init=False)
[docs]
ua: Optional[UserAgent] = field(default=None, init=False)
[docs]
def __post_init__(self) -> None:
self.ua = UserAgent(self.user_agent)
self.uach = UserAgentClientHints(self.user_agent_client_hints)
# приоритет UACH → UA
self.browser_name = _coalesce(self.uach.primary_brand_name, self.ua.browser_name)
self.browser_version = _coalesce(self.uach.primary_brand_version, self.ua.browser_version)
# ОС из UACH platform/version, иначе из UA
self.os_name = _coalesce(self.uach.platform, self.ua.os_name)
self.os_version = _coalesce(self.uach.platform_version, self.ua.os_version)
# тип устройства: улучшенная логика
# 1. UACH.mobile
# 2. Touch support / screen size heuristics
# 3. Fallback to UA
if isinstance(self.uach.mobile, bool):
self.device_type = "mobile" if self.uach.mobile else "desktop"
elif self.touch_support and (
self.touch_support.get("maxTouchPoints", 0) > 0 or self.touch_support.get("touchEvent")
):
# Если touch и маленький экран — mobile, иначе tablet
screen_w = (self.screen or {}).get("width", 0)
self.device_type = "tablet" if screen_w > 768 else "mobile"
else:
self.device_type = self.ua.device_type
# движок — только из UA (UACH его не даёт), но можно доработать по UACH.brands
self.engine = self.ua.engine
if not self.engine and self.uach.primary_brand_name:
if (
"Chromium" in self.uach.primary_brand_name
or "Chrome" in self.uach.primary_brand_name
):
self.engine = "Blink"
elif "Firefox" in self.uach.primary_brand_name:
self.engine = "Gecko"
elif "Safari" in self.uach.primary_brand_name:
self.engine = "WebKit"
# Преобразование dict в датаклассы для новых полей
if self.screen:
self.screen_details = Screen(**self.screen)
if self.window:
self.window_details = WindowDetails(**self.window)
if self.touch_support:
self.touch_support_details = TouchSupport(**self.touch_support)
if self.battery:
self.battery_details = Battery(**self.battery)