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

1from __future__ import annotations 

2 

3import re 

4from dataclasses import dataclass, field 

5from typing import Any, Dict, List, Optional 

6 

7from ua_parser import parse as ua_parse # pip install ua-parser 

8 

9Brand = Dict[str, str] 

10BrandList = List[Brand] 

11 

12 

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 

23 

24 

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 

35 

36 

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]) 

41 

42 

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 

52 

53 

54@dataclass 

55class WindowDetails: 

56 innerWidth: Optional[int] = None 

57 innerHeight: Optional[int] = None 

58 devicePixelRatio: Optional[float] = None 

59 

60 

61@dataclass 

62class TouchSupport: 

63 maxTouchPoints: int = 0 

64 touchEvent: Optional[bool] = None 

65 

66 

67@dataclass 

68class Battery: 

69 level: Optional[float] = None 

70 charging: Optional[bool] = None 

71 

72 

73# ---------- UserAgent ---------- 

74@dataclass 

75class UserAgent: 

76 raw: Optional[str] = None 

77 

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) 

86 

87 def __post_init__(self) -> None: 

88 s = self.raw or "" 

89 r = ua_parse(s) # Result(user_agent=..., os=..., device=...) 

90 

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 ) 

101 

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 ) 

112 

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 

118 

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" 

127 

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 

137 

138 

139# ---------- UserAgentClientHints ---------- 

140@dataclass 

141class UserAgentClientHints: 

142 # ожидаем структуру: 

143 # {"low_entropy": {...}, "high_entropy": {...}} или {"supported": false} 

144 raw: Optional[Dict[str, Any]] = None 

145 

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) 

156 

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) 

160 

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 {} 

165 

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 

176 

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")) 

180 

181 

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 

193 

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 

210 

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) 

218 

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) 

224 

225 uach: Optional[UserAgentClientHints] = field(default=None, init=False) 

226 ua: Optional[UserAgent] = field(default=None, init=False) 

227 

228 def __post_init__(self) -> None: 

229 self.ua = UserAgent(self.user_agent) 

230 self.uach = UserAgentClientHints(self.user_agent_client_hints) 

231 

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) 

235 

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) 

239 

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 

254 

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" 

267 

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)