uaforger 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- uaforge/__init__.py +1 -0
- uaforge/core/__init__.py +0 -0
- uaforge/core/alias_sampler.py +97 -0
- uaforge/core/client_hints.py +232 -0
- uaforge/core/generator.py +410 -0
- uaforge/core/versioning.py +189 -0
- uaforge/data/__init__.py +5 -0
- uaforge/data/android_device_specs.json +8127 -0
- uaforge/data/chrome_versions.json +1 -0
- uaforge/data/chromium_versions.json +1 -0
- uaforge/data/device_models.json +1 -0
- uaforge/data/edge_versions.json +1 -0
- uaforge/data/loader.py +326 -0
- uaforge/data/mappings.py +19 -0
- uaforge/data/market_share.json +1 -0
- uaforge/data/opera_versions.json +1 -0
- uaforge/data/os_distribution.json +1 -0
- uaforge/exceptions.py +14 -0
- uaforge/models/__init__.py +0 -0
- uaforge/models/enums.py +37 -0
- uaforge/models/objects.py +121 -0
- uaforger-0.1.0.dist-info/METADATA +184 -0
- uaforger-0.1.0.dist-info/RECORD +25 -0
- uaforger-0.1.0.dist-info/WHEEL +5 -0
- uaforger-0.1.0.dist-info/top_level.txt +1 -0
uaforge/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
uaforge/core/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import random
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AliasSampler:
|
|
6
|
+
"""
|
|
7
|
+
O(1) weighted random sampler using Vose's Alias Method.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
sampler = AliasSampler(weights, rng)
|
|
11
|
+
index = sampler.sample() # O(1) per call
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, weights: List[float], rng=None):
|
|
15
|
+
"""
|
|
16
|
+
Preprocess weights into alias table.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
weights: List of weights (need not sum to 1, will be normalized)
|
|
20
|
+
rng: Random instance to use (defaults to random module)
|
|
21
|
+
"""
|
|
22
|
+
self.rng = rng if rng is not None else random
|
|
23
|
+
n = len(weights)
|
|
24
|
+
|
|
25
|
+
if n == 0:
|
|
26
|
+
raise ValueError("Cannot create AliasSampler with empty weights")
|
|
27
|
+
|
|
28
|
+
self.n = n
|
|
29
|
+
|
|
30
|
+
# Normalize weights
|
|
31
|
+
total = sum(weights)
|
|
32
|
+
if total <= 0:
|
|
33
|
+
raise ValueError("Sum of weights must be positive")
|
|
34
|
+
|
|
35
|
+
# probabilities normalized to sum to n (for the algorithm)
|
|
36
|
+
prob = [w * n / total for w in weights]
|
|
37
|
+
|
|
38
|
+
# Alias tables
|
|
39
|
+
self.prob = [0.0] * n
|
|
40
|
+
self.alias = [0] * n
|
|
41
|
+
|
|
42
|
+
# Partition into small and large
|
|
43
|
+
small = []
|
|
44
|
+
large = []
|
|
45
|
+
|
|
46
|
+
for i, p in enumerate(prob):
|
|
47
|
+
if p < 1.0:
|
|
48
|
+
small.append(i)
|
|
49
|
+
else:
|
|
50
|
+
large.append(i)
|
|
51
|
+
|
|
52
|
+
# Build alias table
|
|
53
|
+
while small and large:
|
|
54
|
+
l = small.pop()
|
|
55
|
+
g = large.pop()
|
|
56
|
+
|
|
57
|
+
self.prob[l] = prob[l]
|
|
58
|
+
self.alias[l] = g
|
|
59
|
+
|
|
60
|
+
prob[g] = prob[g] + prob[l] - 1.0
|
|
61
|
+
|
|
62
|
+
if prob[g] < 1.0:
|
|
63
|
+
small.append(g)
|
|
64
|
+
else:
|
|
65
|
+
large.append(g)
|
|
66
|
+
|
|
67
|
+
# Remaining items (due to floating point, both could have leftovers)
|
|
68
|
+
while large:
|
|
69
|
+
g = large.pop()
|
|
70
|
+
self.prob[g] = 1.0
|
|
71
|
+
|
|
72
|
+
while small:
|
|
73
|
+
l = small.pop()
|
|
74
|
+
self.prob[l] = 1.0
|
|
75
|
+
|
|
76
|
+
def sample(self, rand: random.Random = None) -> int:
|
|
77
|
+
"""
|
|
78
|
+
Sample an index in O(1) time.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
rand: Optional random instance to use instead of self.rng
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Sampled index
|
|
85
|
+
"""
|
|
86
|
+
rng = rand if rand is not None else self.rng
|
|
87
|
+
# Generate fair die roll
|
|
88
|
+
i = rng.randrange(self.n)
|
|
89
|
+
# Flip biased coin
|
|
90
|
+
if rng.random() < self.prob[i]:
|
|
91
|
+
return i
|
|
92
|
+
else:
|
|
93
|
+
return self.alias[i]
|
|
94
|
+
|
|
95
|
+
def sample_n(self, n: int) -> List[int]:
|
|
96
|
+
"""Sample n indices efficiently."""
|
|
97
|
+
return [self.sample() for _ in range(n)]
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import random
|
|
2
|
+
from typing import List, Tuple, Optional, TYPE_CHECKING
|
|
3
|
+
from ..models.enums import BrowserFamily, DeviceType
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from ..data.loader import DataLoader
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ClientHintsGenerator:
|
|
10
|
+
"""
|
|
11
|
+
Generates the modern 'Sec-CH-UA' headers.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
# General GREASE token
|
|
15
|
+
GREASE_BRAND = list("Not A Brand")
|
|
16
|
+
PUNCT = ";:()_ "
|
|
17
|
+
_brand_base_cache = {}
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def _format_brand_list(brands: List[Tuple[str, str]]) -> str:
|
|
21
|
+
parts = []
|
|
22
|
+
for brand, version in brands:
|
|
23
|
+
parts.append(f'"{brand}";v="{version}"')
|
|
24
|
+
return ", ".join(parts)
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def _get_brand_tuples(cls, family: BrowserFamily, version: str, rand=None) -> List[Tuple[str, str]]:
|
|
28
|
+
"""Randomized brand tuples. Accepts an optional RNG instance for faster and thread-safe sampling."""
|
|
29
|
+
if rand is None:
|
|
30
|
+
rand = random
|
|
31
|
+
|
|
32
|
+
brands: List[Tuple[str, str]] = []
|
|
33
|
+
name = cls.GREASE_BRAND.copy()
|
|
34
|
+
space_idxs = [i for i, c in enumerate(name) if c == " "]
|
|
35
|
+
|
|
36
|
+
if len(space_idxs) >= 2:
|
|
37
|
+
sep1, sep2 = rand.choices(cls.PUNCT, k=2)
|
|
38
|
+
name[space_idxs[0]] = sep1
|
|
39
|
+
name[space_idxs[1]] = sep2
|
|
40
|
+
|
|
41
|
+
grease_name = "".join(name)
|
|
42
|
+
grease_version = rand.choice(("99", "8", "24"))
|
|
43
|
+
brands.append((grease_name, grease_version))
|
|
44
|
+
|
|
45
|
+
if family == BrowserFamily.CHROME:
|
|
46
|
+
brands.append(("Chromium", version))
|
|
47
|
+
brands.append(("Google Chrome", version))
|
|
48
|
+
elif family == BrowserFamily.EDGE:
|
|
49
|
+
brands.append(("Chromium", version))
|
|
50
|
+
brands.append(("Microsoft Edge", version))
|
|
51
|
+
elif family == BrowserFamily.OPERA:
|
|
52
|
+
brands.append(("Chromium", version))
|
|
53
|
+
brands.append(("Opera", version))
|
|
54
|
+
else:
|
|
55
|
+
brands.append(("Chromium", version))
|
|
56
|
+
|
|
57
|
+
# Shuffle to randomize order
|
|
58
|
+
rand.shuffle(brands)
|
|
59
|
+
|
|
60
|
+
return brands
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def generate_brands(cls, family: BrowserFamily, major_version: str, rand=None) -> str:
|
|
64
|
+
"""
|
|
65
|
+
Constructs the Sec-CH-UA header value (Major versions only).
|
|
66
|
+
Returns EMPTY STRING for Safari/Firefox.
|
|
67
|
+
|
|
68
|
+
For Edge and Opera, uses appropriate Chromium major version:
|
|
69
|
+
- Edge: Same major version as Edge
|
|
70
|
+
- Opera: Opera major version + 16
|
|
71
|
+
"""
|
|
72
|
+
if family in (BrowserFamily.FIREFOX, BrowserFamily.SAFARI):
|
|
73
|
+
return ""
|
|
74
|
+
|
|
75
|
+
# Get Chromium major version for Edge and Opera
|
|
76
|
+
chromium_major = None
|
|
77
|
+
if family == BrowserFamily.EDGE:
|
|
78
|
+
chromium_major = major_version # Edge uses same major as Chromium
|
|
79
|
+
elif family == BrowserFamily.OPERA:
|
|
80
|
+
try:
|
|
81
|
+
chromium_major = str(int(major_version) + 16)
|
|
82
|
+
except ValueError:
|
|
83
|
+
chromium_major = major_version
|
|
84
|
+
|
|
85
|
+
# Build the brand list
|
|
86
|
+
parts = []
|
|
87
|
+
parts.append('"Not A Brand";v="99"')
|
|
88
|
+
|
|
89
|
+
if family == BrowserFamily.CHROME:
|
|
90
|
+
parts.append(f'"Chromium";v="{major_version}"')
|
|
91
|
+
parts.append(f'"Google Chrome";v="{major_version}"')
|
|
92
|
+
elif family == BrowserFamily.EDGE:
|
|
93
|
+
parts.append(f'"Chromium";v="{chromium_major}"')
|
|
94
|
+
parts.append(f'"Microsoft Edge";v="{major_version}"')
|
|
95
|
+
elif family == BrowserFamily.OPERA:
|
|
96
|
+
parts.append(f'"Chromium";v="{chromium_major}"')
|
|
97
|
+
parts.append(f'"Opera";v="{major_version}"')
|
|
98
|
+
else:
|
|
99
|
+
parts.append(f'"Chromium";v="{major_version}"')
|
|
100
|
+
|
|
101
|
+
return ", ".join(parts)
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def get_major_chromium_full_version(cls, family: BrowserFamily, full_version: str, rand=None, loader: Optional['DataLoader'] = None) -> Optional[int]:
|
|
105
|
+
chromium_version = None
|
|
106
|
+
if loader:
|
|
107
|
+
if family == BrowserFamily.EDGE:
|
|
108
|
+
try:
|
|
109
|
+
major = full_version.split('.')[0]
|
|
110
|
+
chromium_version = loader.get_chromium_version_for_edge(major)
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
elif family == BrowserFamily.OPERA:
|
|
114
|
+
try:
|
|
115
|
+
major = full_version.split('.')[0]
|
|
116
|
+
chromium_version = loader.get_chromium_version_for_opera(major)
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
if chromium_version:
|
|
120
|
+
return int(chromium_version.split('.')[0])
|
|
121
|
+
elif family == BrowserFamily.CHROME:
|
|
122
|
+
return int(full_version.split('.')[0])
|
|
123
|
+
return -1
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@classmethod
|
|
127
|
+
def generate_full_version_list(cls, family: BrowserFamily, full_version: str, rand=None, loader: Optional['DataLoader'] = None) -> str:
|
|
128
|
+
"""
|
|
129
|
+
Constructs the Sec-CH-UA-Full-Version-List header.
|
|
130
|
+
Returns EMPTY STRING for Safari/Firefox.
|
|
131
|
+
|
|
132
|
+
For Edge and Opera, uses appropriate Chromium version:
|
|
133
|
+
- Edge: Same major version as Edge
|
|
134
|
+
- Opera: Opera major version + 16
|
|
135
|
+
"""
|
|
136
|
+
if family in (BrowserFamily.FIREFOX, BrowserFamily.SAFARI):
|
|
137
|
+
return ""
|
|
138
|
+
|
|
139
|
+
# Get Chromium version for Edge and Opera
|
|
140
|
+
chromium_version = None
|
|
141
|
+
if loader:
|
|
142
|
+
if family == BrowserFamily.EDGE:
|
|
143
|
+
try:
|
|
144
|
+
major = full_version.split('.')[0]
|
|
145
|
+
chromium_version = loader.get_chromium_version_for_edge(major)
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
elif family == BrowserFamily.OPERA:
|
|
149
|
+
try:
|
|
150
|
+
major = full_version.split('.')[0]
|
|
151
|
+
chromium_version = loader.get_chromium_version_for_opera(major)
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
# Build the version list
|
|
156
|
+
parts = []
|
|
157
|
+
parts.append('"Not A Brand";v="99.0.0.0"')
|
|
158
|
+
|
|
159
|
+
if family == BrowserFamily.CHROME:
|
|
160
|
+
parts.append(f'"Chromium";v="{full_version}"')
|
|
161
|
+
parts.append(f'"Google Chrome";v="{full_version}"')
|
|
162
|
+
elif family == BrowserFamily.EDGE:
|
|
163
|
+
if chromium_version:
|
|
164
|
+
parts.append(f'"Chromium";v="{chromium_version}"')
|
|
165
|
+
else:
|
|
166
|
+
parts.append(f'"Chromium";v="{full_version}"')
|
|
167
|
+
parts.append(f'"Microsoft Edge";v="{full_version}"')
|
|
168
|
+
elif family == BrowserFamily.OPERA:
|
|
169
|
+
if chromium_version:
|
|
170
|
+
parts.append(f'"Chromium";v="{chromium_version}"')
|
|
171
|
+
else:
|
|
172
|
+
# Fallback: calculate Chromium version
|
|
173
|
+
major = int(full_version.split('.')[0])
|
|
174
|
+
chromium_major = major + 16
|
|
175
|
+
parts.append(f'"Chromium";v="{chromium_major}.0.0.0"')
|
|
176
|
+
parts.append(f'"Opera";v="{full_version}"')
|
|
177
|
+
else:
|
|
178
|
+
parts.append(f'"Chromium";v="{full_version}"')
|
|
179
|
+
|
|
180
|
+
return ", ".join(parts)
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def get_mobile_token(is_mobile: bool) -> str:
|
|
184
|
+
return "?1" if is_mobile else "?0"
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def get_platform_token(platform: str) -> str:
|
|
188
|
+
return platform
|
|
189
|
+
|
|
190
|
+
@classmethod
|
|
191
|
+
def generate_full_version(cls, family: BrowserFamily, full_version: str) -> str:
|
|
192
|
+
"""
|
|
193
|
+
Constructs the Sec-CH-UA-Full-Version header.
|
|
194
|
+
Returns EMPTY STRING for Safari/Firefox.
|
|
195
|
+
"""
|
|
196
|
+
if family in (BrowserFamily.FIREFOX, BrowserFamily.SAFARI):
|
|
197
|
+
return ""
|
|
198
|
+
|
|
199
|
+
# Return the full version as-is (e.g., "142.0.7444.175")
|
|
200
|
+
return full_version
|
|
201
|
+
|
|
202
|
+
@classmethod
|
|
203
|
+
def generate_form_factors(cls, device_type: DeviceType, rand=None) -> str:
|
|
204
|
+
"""
|
|
205
|
+
Constructs the Sec-CH-UA-Form-Factors header.
|
|
206
|
+
"""
|
|
207
|
+
if rand is None:
|
|
208
|
+
rand = random
|
|
209
|
+
|
|
210
|
+
if device_type == DeviceType.DESKTOP:
|
|
211
|
+
return "Desktop"
|
|
212
|
+
elif device_type == DeviceType.TABLET:
|
|
213
|
+
return "Tablet"
|
|
214
|
+
else: # MOBILE
|
|
215
|
+
return "Mobile"
|
|
216
|
+
|
|
217
|
+
@staticmethod
|
|
218
|
+
def get_wow64_token(is_wow64: bool) -> str:
|
|
219
|
+
"""
|
|
220
|
+
Constructs the Sec-CH-UA-WoW64 header.
|
|
221
|
+
"""
|
|
222
|
+
return "?1" if is_wow64 else "?0"
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def get_prefers_color_scheme(rand=None) -> str:
|
|
226
|
+
"""
|
|
227
|
+
Constructs the Sec-CH-Prefers-Color-Scheme header.
|
|
228
|
+
"""
|
|
229
|
+
if rand is None:
|
|
230
|
+
rand = random
|
|
231
|
+
# Randomly choose between light and dark themes
|
|
232
|
+
return rand.choice(["light", "dark"])
|