Coverage for human_requests / fingerprint / fingerprint.py: 53%
180 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-25 10:02 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-25 10:02 +0000
1from __future__ import annotations
3import re
4from dataclasses import dataclass, field
5from typing import Any, Dict, List, Optional
7from ua_parser import parse as ua_parse # pip install ua-parser
9Brand = Dict[str, str]
10BrandList = List[Brand]
13# ---------- утилиты ----------
14def _coalesce(*vals: Any) -> Any:
15 """
16 Возвращает первый «содержательный» элемент из vals
17 (не None, не пустую строку, не пустые список/словарь), иначе None.
18 """
19 for v in vals:
20 if v not in (None, "", [], {}):
21 return v
22 return None
25def _join_version(*parts: Optional[str]) -> Optional[str]:
26 """
27 Склеивает части версии через точку, пропуская пустые/None/мусор вроде '0-0'.
28 """
29 filtered: List[str] = []
30 for p in parts:
31 if p is None or p in ("", "0-0"):
32 continue
33 filtered.append(p)
34 return ".".join(filtered) if filtered else None
37def _primary_brand(brands: Optional[BrandList]) -> Optional[Brand]:
38 if not brands:
39 return None
40 return next((b for b in brands if "Not=A?Brand" not in (b.get("brand") or "")), brands[0])
43# ---------- Новые вложенные датаклассы ----------
44@dataclass
45class Screen:
46 width: Optional[int] = None
47 height: Optional[int] = None
48 availWidth: Optional[int] = None
49 availHeight: Optional[int] = None
50 colorDepth: Optional[int] = None
51 pixelDepth: Optional[int] = None
54@dataclass
55class WindowDetails:
56 innerWidth: Optional[int] = None
57 innerHeight: Optional[int] = None
58 devicePixelRatio: Optional[float] = None
61@dataclass
62class TouchSupport:
63 maxTouchPoints: int = 0
64 touchEvent: Optional[bool] = None
67@dataclass
68class Battery:
69 level: Optional[float] = None
70 charging: Optional[bool] = None
73# ---------- UserAgent ----------
74@dataclass
75class UserAgent:
76 raw: Optional[str] = None
78 browser_name: Optional[str] = field(default=None, init=False)
79 browser_version: Optional[str] = field(default=None, init=False)
80 os_name: Optional[str] = field(default=None, init=False)
81 os_version: Optional[str] = field(default=None, init=False)
82 device_brand: Optional[str] = field(default=None, init=False)
83 device_model: Optional[str] = field(default=None, init=False)
84 device_type: Optional[str] = field(default=None, init=False) # 'mobile'|'tablet'|'desktop'
85 engine: Optional[str] = field(default=None, init=False)
87 def __post_init__(self) -> None:
88 s = self.raw or ""
89 r = ua_parse(s) # Result(user_agent=..., os=..., device=...)
91 # --- браузер ---
92 ua = getattr(r, "user_agent", None)
93 if ua is not None:
94 self.browser_name = ua.family or None
95 self.browser_version = _join_version(
96 getattr(ua, "major", None),
97 getattr(ua, "minor", None),
98 getattr(ua, "patch", None),
99 getattr(ua, "patch_minor", None),
100 )
102 # --- ОС ---
103 os = getattr(r, "os", None)
104 if os is not None:
105 self.os_name = os.family or None
106 self.os_version = _join_version(
107 getattr(os, "major", None),
108 getattr(os, "minor", None),
109 getattr(os, "patch", None),
110 getattr(os, "patch_minor", None),
111 )
113 # --- устройство ---
114 dev = getattr(r, "device", None)
115 if dev is not None:
116 self.device_brand = getattr(dev, "brand", None) or None
117 self.device_model = getattr(dev, "model", None) or None
119 # тип устройства (просто и без эвристик уровня ML)
120 low = s.lower()
121 if "tablet" in low or "ipad" in low:
122 self.device_type = "tablet"
123 elif "mobile" in low:
124 self.device_type = "mobile"
125 else:
126 self.device_type = "desktop"
128 # движок по явным признакам
129 if "gecko/" in low and "firefox/" in low:
130 self.engine = "Gecko"
131 elif "applewebkit/" in low and re.search(r"(chrome|crios|edg|opr|yabrowser)/", low):
132 self.engine = "Blink"
133 elif "applewebkit/" in low:
134 self.engine = "WebKit"
135 else:
136 self.engine = None
139# ---------- UserAgentClientHints ----------
140@dataclass
141class UserAgentClientHints:
142 # ожидаем структуру:
143 # {"low_entropy": {...}, "high_entropy": {...}} или {"supported": false}
144 raw: Optional[Dict[str, Any]] = None
146 supported: Optional[bool] = field(default=None, init=False)
147 mobile: Optional[bool] = field(default=None, init=False)
148 brands: Optional[BrandList] = field(default=None, init=False)
149 full_version_list: Optional[BrandList] = field(default=None, init=False)
150 ua_full_version: Optional[str] = field(default=None, init=False)
151 architecture: Optional[str] = field(default=None, init=False)
152 bitness: Optional[str] = field(default=None, init=False)
153 model: Optional[str] = field(default=None, init=False)
154 platform: Optional[str] = field(default=None, init=False)
155 platform_version: Optional[str] = field(default=None, init=False)
157 # удобное: «основной» бренд (name+version)
158 primary_brand_name: Optional[str] = field(default=None, init=False)
159 primary_brand_version: Optional[str] = field(default=None, init=False)
161 def __post_init__(self) -> None:
162 d: Dict[str, Any] = self.raw or {}
163 low: Dict[str, Any] = d.get("low_entropy") or {}
164 high: Dict[str, Any] = d.get("high_entropy") or {}
166 self.supported = False if d.get("supported") is False else (None if not d else True)
167 self.mobile = low.get("mobile", high.get("mobile"))
168 self.brands = (low.get("brands") or high.get("brands")) or None
169 self.full_version_list = high.get("fullVersionList") or None
170 self.ua_full_version = high.get("uaFullVersion") or None
171 self.architecture = high.get("architecture") or None
172 self.bitness = high.get("bitness") or None
173 self.model = (high.get("model") or "") or None
174 self.platform = high.get("platform") or None
175 self.platform_version = high.get("platformVersion") or None
177 if pb := _primary_brand(self.full_version_list or self.brands):
178 self.primary_brand_name = pb.get("brand") or None
179 self.primary_brand_version = _coalesce(self.ua_full_version, pb.get("version"))
182# ---------- Fingerprint ----------
183@dataclass
184class Fingerprint:
185 # сырые входы
186 user_agent: Optional[str] = None
187 user_agent_client_hints: Optional[Dict[str, Any]] = None
188 headers: Optional[Dict[str, str]] = None
189 platform: Optional[str] = None
190 vendor: Optional[str] = None
191 languages: Optional[List[str]] = None
192 timezone: Optional[str] = None
194 # новые сырые входы из JS
195 screen: Optional[Dict[str, Any]] = None
196 window: Optional[Dict[str, Any]] = None
197 hardware_concurrency: Optional[int] = None
198 device_memory: Optional[float] = None
199 cookies_enabled: Optional[bool] = None
200 local_storage: Optional[bool] = None
201 session_storage: Optional[bool] = None
202 do_not_track: Optional[str] = None
203 touch_support: Optional[Dict[str, Any]] = None
204 orientation: Optional[str] = None
205 battery: Optional[Dict[str, Any]] = None
206 canvas_fingerprint: Optional[str] = None
207 webgl_fingerprint: Optional[str] = None
208 audio_fingerprint: Optional[str] = None
209 fonts: Optional[List[str]] = None
211 # итоговые поля (UACH имеет приоритет, затем UA)
212 browser_name: Optional[str] = field(default=None, init=False)
213 browser_version: Optional[str] = field(default=None, init=False)
214 os_name: Optional[str] = field(default=None, init=False)
215 os_version: Optional[str] = field(default=None, init=False)
216 device_type: Optional[str] = field(default=None, init=False)
217 engine: Optional[str] = field(default=None, init=False)
219 # структурированные новые
220 screen_details: Optional[Screen] = field(default=None, init=False)
221 window_details: Optional[WindowDetails] = field(default=None, init=False)
222 touch_support_details: Optional[TouchSupport] = field(default=None, init=False)
223 battery_details: Optional[Battery] = field(default=None, init=False)
225 uach: Optional[UserAgentClientHints] = field(default=None, init=False)
226 ua: Optional[UserAgent] = field(default=None, init=False)
228 def __post_init__(self) -> None:
229 self.ua = UserAgent(self.user_agent)
230 self.uach = UserAgentClientHints(self.user_agent_client_hints)
232 # приоритет UACH → UA
233 self.browser_name = _coalesce(self.uach.primary_brand_name, self.ua.browser_name)
234 self.browser_version = _coalesce(self.uach.primary_brand_version, self.ua.browser_version)
236 # ОС из UACH platform/version, иначе из UA
237 self.os_name = _coalesce(self.uach.platform, self.ua.os_name)
238 self.os_version = _coalesce(self.uach.platform_version, self.ua.os_version)
240 # тип устройства: улучшенная логика
241 # 1. UACH.mobile
242 # 2. Touch support / screen size heuristics
243 # 3. Fallback to UA
244 if isinstance(self.uach.mobile, bool):
245 self.device_type = "mobile" if self.uach.mobile else "desktop"
246 elif self.touch_support and (
247 self.touch_support.get("maxTouchPoints", 0) > 0 or self.touch_support.get("touchEvent")
248 ):
249 # Если touch и маленький экран — mobile, иначе tablet
250 screen_w = (self.screen or {}).get("width", 0)
251 self.device_type = "tablet" if screen_w > 768 else "mobile"
252 else:
253 self.device_type = self.ua.device_type
255 # движок — только из UA (UACH его не даёт), но можно доработать по UACH.brands
256 self.engine = self.ua.engine
257 if not self.engine and self.uach.primary_brand_name:
258 if (
259 "Chromium" in self.uach.primary_brand_name
260 or "Chrome" in self.uach.primary_brand_name
261 ):
262 self.engine = "Blink"
263 elif "Firefox" in self.uach.primary_brand_name:
264 self.engine = "Gecko"
265 elif "Safari" in self.uach.primary_brand_name:
266 self.engine = "WebKit"
268 # Преобразование dict в датаклассы для новых полей
269 if self.screen:
270 self.screen_details = Screen(**self.screen)
271 if self.window:
272 self.window_details = WindowDetails(**self.window)
273 if self.touch_support:
274 self.touch_support_details = TouchSupport(**self.touch_support)
275 if self.battery:
276 self.battery_details = Battery(**self.battery)