bas-http 1.0.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.
bas/curl_parser.py ADDED
@@ -0,0 +1,267 @@
1
+ """
2
+ Parse curl commands into bas requests.
3
+
4
+ The workflow:
5
+ 1. Open browser DevTools -> Network tab
6
+ 2. Right-click a request -> "Copy as cURL"
7
+ 3. Paste into bas
8
+
9
+ bas parses it and gives you a ready-to-go Session with all
10
+ headers, cookies, and settings extracted.
11
+
12
+ Handles both Windows (^") and Unix (\') escaping, and escaped quotes
13
+ inside header values like sec-ch-ua.
14
+ """
15
+
16
+ import re
17
+ from typing import Optional
18
+ from .cookies import Cookie, CookieJar
19
+ from .models import Headers
20
+
21
+
22
+ def _tokenize_curl(cmd: str) -> list[str]:
23
+ """
24
+ Tokenize a curl command string, handling:
25
+ - Double-quoted strings with escaped quotes inside (\")
26
+ - Single-quoted strings
27
+ - Unquoted tokens
28
+ - Windows ^" escaping
29
+ - Windows ^ line continuations
30
+ """
31
+ # Normalize Windows line continuations
32
+ cmd = re.sub(r'\s*\^\s*\n', ' ', cmd)
33
+ # Windows caret-quote: ^" -> "
34
+ cmd = cmd.replace('^"', '"').replace('^"', '"')
35
+ # Windows ^$ -> $
36
+ cmd = cmd.replace('^$', '$')
37
+
38
+ tokens = []
39
+ i = 0
40
+ while i < len(cmd):
41
+ # Skip whitespace
42
+ if cmd[i].isspace():
43
+ i += 1
44
+ continue
45
+
46
+ if cmd[i] == '"':
47
+ # Double-quoted string — handle escaped quotes \"
48
+ i += 1 # skip opening quote
49
+ buf = []
50
+ while i < len(cmd):
51
+ if cmd[i] == '\\' and i + 1 < len(cmd) and cmd[i + 1] == '"':
52
+ buf.append('"')
53
+ i += 2
54
+ elif cmd[i] == '"':
55
+ i += 1 # skip closing quote
56
+ break
57
+ else:
58
+ buf.append(cmd[i])
59
+ i += 1
60
+ tokens.append(''.join(buf))
61
+
62
+ elif cmd[i] == "'":
63
+ # Single-quoted string — no escaping
64
+ i += 1
65
+ buf = []
66
+ while i < len(cmd):
67
+ if cmd[i] == "'":
68
+ i += 1
69
+ break
70
+ else:
71
+ buf.append(cmd[i])
72
+ i += 1
73
+ tokens.append(''.join(buf))
74
+
75
+ else:
76
+ # Unquoted token
77
+ buf = []
78
+ while i < len(cmd) and not cmd[i].isspace():
79
+ buf.append(cmd[i])
80
+ i += 1
81
+ tokens.append(''.join(buf))
82
+
83
+ return tokens
84
+
85
+
86
+ def parse_curl(curl_cmd: str) -> dict:
87
+ """
88
+ Parse a curl command string into its components.
89
+
90
+ Handles Windows (^"), Unix (\'), and escaped quotes inside values.
91
+
92
+ Returns:
93
+ {
94
+ "method": "GET" | "POST" | "PUT" | ...,
95
+ "url": "https://...",
96
+ "headers": {"key": "value", ...},
97
+ "cookies": {"name": "value", ...},
98
+ "cookie_header": "raw cookie string",
99
+ "data": "post body" or None,
100
+ "user_agent": "...",
101
+ "referer": "...",
102
+ "origin": "...",
103
+ }
104
+ """
105
+ result = {
106
+ "method": "GET",
107
+ "url": "",
108
+ "headers": {},
109
+ "cookies": {},
110
+ "cookie_header": "",
111
+ "data": None,
112
+ "user_agent": "",
113
+ "referer": "",
114
+ "origin": "",
115
+ }
116
+
117
+ tokens = _tokenize_curl(curl_cmd)
118
+
119
+ # Skip "curl" token
120
+ start = 0
121
+ if tokens and tokens[0].lower() == "curl":
122
+ start = 1
123
+
124
+ # First non-flag token is the URL
125
+ i = start
126
+ while i < len(tokens):
127
+ if tokens[i].startswith("-"):
128
+ # It's a flag, skip for now to find URL
129
+ if tokens[i] in ("-H", "-b", "-d", "-X", "-A", "-e", "--data"):
130
+ i += 2 # skip flag + value
131
+ else:
132
+ i += 1
133
+ else:
134
+ # It's the URL
135
+ result["url"] = tokens[i]
136
+ i += 1
137
+ break
138
+
139
+ # Now parse flags
140
+ while i < len(tokens):
141
+ token = tokens[i]
142
+
143
+ if token == "-H" and i + 1 < len(tokens):
144
+ val = tokens[i + 1]
145
+ if ":" in val:
146
+ key, _, value = val.partition(":")
147
+ key = key.strip()
148
+ value = value.strip()
149
+ result["headers"][key] = value
150
+
151
+ lk = key.lower()
152
+ if lk == "user-agent":
153
+ result["user_agent"] = value
154
+ elif lk == "referer":
155
+ result["referer"] = value
156
+ elif lk == "origin":
157
+ result["origin"] = value
158
+ i += 2
159
+
160
+ elif token in ("-b", "--cookie") and i + 1 < len(tokens):
161
+ cookie_str = tokens[i + 1]
162
+ result["cookie_header"] = cookie_str
163
+ for pair in cookie_str.split(";"):
164
+ pair = pair.strip()
165
+ if "=" in pair:
166
+ name, _, value = pair.partition("=")
167
+ name = name.strip()
168
+ value = value.strip()
169
+ if name:
170
+ result["cookies"][name] = value
171
+ i += 2
172
+
173
+ elif token in ("-d", "--data", "--data-raw") and i + 1 < len(tokens):
174
+ result["data"] = tokens[i + 1]
175
+ if "content-type" not in {k.lower(): v for k, v in result["headers"].items()}:
176
+ result["headers"]["Content-Type"] = "application/x-www-form-urlencoded"
177
+ i += 2
178
+
179
+ elif token == "-X" and i + 1 < len(tokens):
180
+ result["method"] = tokens[i + 1].upper()
181
+ i += 2
182
+
183
+ elif token in ("-A", "--user-agent") and i + 1 < len(tokens):
184
+ result["headers"]["User-Agent"] = tokens[i + 1]
185
+ result["user_agent"] = tokens[i + 1]
186
+ i += 2
187
+
188
+ elif token in ("-e", "--referer") and i + 1 < len(tokens):
189
+ result["headers"]["Referer"] = tokens[i + 1]
190
+ result["referer"] = tokens[i + 1]
191
+ i += 2
192
+
193
+ elif token == "--compressed":
194
+ i += 1
195
+
196
+ elif token == "-k":
197
+ i += 1
198
+
199
+ elif token.startswith("-"):
200
+ # Unknown flag, skip it and its value if it looks like it takes one
201
+ i += 1
202
+ if i < len(tokens) and not tokens[i].startswith("-"):
203
+ i += 1
204
+ else:
205
+ i += 1
206
+
207
+ if result["data"] and result["method"] == "GET":
208
+ result["method"] = "POST"
209
+
210
+ return result
211
+
212
+
213
+ def curl_to_session(curl_cmd: str, **kwargs):
214
+ """
215
+ Parse a curl command and return a pre-configured Session.
216
+
217
+ Usage:
218
+ >>> s = curl_to_session('''curl "https://example.com" -H "User-Agent: ..." -b "cookie=value"''')
219
+ >>> r = s.get("https://example.com/other-page")
220
+ """
221
+ from .simple_session import Session
222
+
223
+ parsed = parse_curl(curl_cmd)
224
+
225
+ s = Session(**kwargs)
226
+ s.headers.update(parsed["headers"])
227
+
228
+ url = parsed["url"]
229
+ if url:
230
+ from urllib.parse import urlparse
231
+ domain = urlparse(url).hostname or ""
232
+ for name, value in parsed["cookies"].items():
233
+ s.set_cookie(name, value, domain=domain, path="/")
234
+
235
+ return s
236
+
237
+
238
+ def print_curl_summary(curl_cmd: str) -> None:
239
+ """Print a human-readable summary of a curl command."""
240
+ parsed = parse_curl(curl_cmd)
241
+
242
+ print("=" * 60)
243
+ print("CURL COMMAND SUMMARY")
244
+ print("=" * 60)
245
+ print(f" Method: {parsed['method']}")
246
+ print(f" URL: {parsed['url']}")
247
+ print(f" Headers: {len(parsed['headers'])}")
248
+ print(f" Cookies: {len(parsed['cookies'])}")
249
+
250
+ if parsed["data"]:
251
+ data_preview = parsed["data"][:80] + ("..." if len(parsed["data"]) > 80 else "")
252
+ print(f" Body: {data_preview}")
253
+
254
+ print()
255
+ print("Headers:")
256
+ for key, value in parsed["headers"].items():
257
+ preview = value[:60] + ("..." if len(value) > 60 else "")
258
+ print(f" {key}: {preview}")
259
+
260
+ if parsed["cookies"]:
261
+ print()
262
+ print("Cookies:")
263
+ for name, value in parsed["cookies"].items():
264
+ preview = value[:40] + ("..." if len(value) > 40 else "")
265
+ print(f" {name} = {preview}")
266
+
267
+ print("=" * 60)
bas/impersonate.py ADDED
@@ -0,0 +1,424 @@
1
+ """
2
+ Browser impersonation profiles.
3
+
4
+ This module provides browser fingerprint profiles that override HTTP/2
5
+ settings and headers to mimic real browsers. When curl-impersonate is
6
+ available, it also enables TLS fingerprint mimicry.
7
+
8
+ Unlike curlcffi which bundles impersonation in a single enum, bas
9
+ separates the concerns:
10
+ - TLS impersonation (requires curl-impersonate library)
11
+ - HTTP/2 fingerprinting (works with standard libcurl)
12
+ - Header ordering and values (always works)
13
+ """
14
+
15
+ from typing import Optional
16
+
17
+
18
+ class BrowserProfile:
19
+ """
20
+ Defines how to mimic a specific browser.
21
+
22
+ Attributes:
23
+ name: Human-readable browser name
24
+ h2_settings: HTTP/2 SETTINGS frame parameters
25
+ h2_weight: HTTP/2 priority stream weight
26
+ h2_depends_on: HTTP/2 priority stream dependency
27
+ h2_exclusive: HTTP/2 priority stream exclusive flag
28
+ tls_version: Minimum TLS version to advertise
29
+ http_headers: Headers to send (in order)
30
+ pseudo_headers: HTTP/2 pseudo-headers order
31
+ header_order: Header order (True = use defined order)
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ name: str,
37
+ h2_settings: Optional[dict] = None,
38
+ h2_weight: int = 1,
39
+ h2_depends_on: int = 0,
40
+ h2_exclusive: bool = False,
41
+ tls_version: str = "1.3",
42
+ http_headers: Optional[dict] = None,
43
+ pseudo_headers: Optional[list] = None,
44
+ header_order: bool = True,
45
+ curl_impersonate: Optional[str] = None,
46
+ ):
47
+ self.name = name
48
+ self.h2_settings = h2_settings or {}
49
+ self.h2_weight = h2_weight
50
+ self.h2_depends_on = h2_depends_on
51
+ self.h2_exclusive = h2_exclusive
52
+ self.tls_version = tls_version
53
+ self.http_headers = http_headers or {}
54
+ self.pseudo_headers = pseudo_headers or []
55
+ self.header_order = header_order
56
+ self.curl_impersonate = curl_impersonate # curl-impersonate profile name
57
+
58
+ def __repr__(self):
59
+ return f"BrowserProfile({self.name!r})"
60
+
61
+
62
+ # ── Pre-defined browser profiles ─────────────────────────────────────────────
63
+
64
+ CHROME131 = BrowserProfile(
65
+ name="chrome131",
66
+ curl_impersonate="chrome131",
67
+ h2_settings={
68
+ "HEADER_TABLE_SIZE": 65536,
69
+ "MAX_CONCURRENT_STREAMS": 1000,
70
+ "INITIAL_WINDOW_SIZE": 6291456,
71
+ "MAX_FRAME_SIZE": 16384,
72
+ "MAX_HEADER_LIST_SIZE": 262144,
73
+ },
74
+ h2_weight=256,
75
+ http_headers={
76
+ "User-Agent": (
77
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
78
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
79
+ "Chrome/131.0.0.0 Safari/537.36"
80
+ ),
81
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
82
+ "Accept-Language": "en-US,en;q=0.9",
83
+ "Accept-Encoding": "gzip, deflate, br, zstd",
84
+ "Sec-Ch-Ua": '"Chromium";v="131", "Not_A Brand";v="24"',
85
+ "Sec-Ch-Ua-Mobile": "?0",
86
+ "Sec-Ch-Ua-Platform": '"Windows"',
87
+ "Sec-Fetch-Dest": "document",
88
+ "Sec-Fetch-Mode": "navigate",
89
+ "Sec-Fetch-Site": "none",
90
+ "Sec-Fetch-User": "?1",
91
+ "Upgrade-Insecure-Requests": "1",
92
+ },
93
+ )
94
+
95
+ CHROME124 = BrowserProfile(
96
+ name="chrome124",
97
+ curl_impersonate="chrome124",
98
+ h2_settings={
99
+ "HEADER_TABLE_SIZE": 65536,
100
+ "MAX_CONCURRENT_STREAMS": 1000,
101
+ "INITIAL_WINDOW_SIZE": 6291456,
102
+ "MAX_FRAME_SIZE": 16384,
103
+ "MAX_HEADER_LIST_SIZE": 262144,
104
+ },
105
+ http_headers={
106
+ "User-Agent": (
107
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
108
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
109
+ "Chrome/124.0.0.0 Safari/537.36"
110
+ ),
111
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
112
+ "Accept-Language": "en-US,en;q=0.9",
113
+ "Accept-Encoding": "gzip, deflate, br",
114
+ "Sec-Ch-Ua": '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
115
+ "Sec-Ch-Ua-Mobile": "?0",
116
+ "Sec-Ch-Ua-Platform": '"Windows"',
117
+ "Sec-Fetch-Dest": "document",
118
+ "Sec-Fetch-Mode": "navigate",
119
+ "Sec-Fetch-Site": "none",
120
+ "Sec-Fetch-User": "?1",
121
+ "Upgrade-Insecure-Requests": "1",
122
+ },
123
+ )
124
+
125
+ CHROME120 = BrowserProfile(
126
+ name="chrome120",
127
+ curl_impersonate="chrome120",
128
+ h2_settings={
129
+ "HEADER_TABLE_SIZE": 65536,
130
+ "MAX_CONCURRENT_STREAMS": 1000,
131
+ "INITIAL_WINDOW_SIZE": 6291456,
132
+ "MAX_FRAME_SIZE": 16384,
133
+ "MAX_HEADER_LIST_SIZE": 262144,
134
+ },
135
+ http_headers={
136
+ "User-Agent": (
137
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
138
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
139
+ "Chrome/120.0.0.0 Safari/537.36"
140
+ ),
141
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
142
+ "Accept-Language": "en-US,en;q=0.9",
143
+ "Accept-Encoding": "gzip, deflate, br",
144
+ "Sec-Ch-Ua": '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
145
+ "Sec-Ch-Ua-Mobile": "?0",
146
+ "Sec-Ch-Ua-Platform": '"Windows"',
147
+ "Sec-Fetch-Dest": "document",
148
+ "Sec-Fetch-Mode": "navigate",
149
+ "Sec-Fetch-Site": "none",
150
+ "Sec-Fetch-User": "?1",
151
+ "Upgrade-Insecure-Requests": "1",
152
+ },
153
+ )
154
+
155
+ CHROME110 = BrowserProfile(
156
+ name="chrome110",
157
+ curl_impersonate="chrome110",
158
+ h2_settings={
159
+ "HEADER_TABLE_SIZE": 65536,
160
+ "MAX_CONCURRENT_STREAMS": 1000,
161
+ "INITIAL_WINDOW_SIZE": 6291456,
162
+ "MAX_FRAME_SIZE": 16384,
163
+ "MAX_HEADER_LIST_SIZE": 262144,
164
+ },
165
+ http_headers={
166
+ "User-Agent": (
167
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
168
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
169
+ "Chrome/110.0.0.0 Safari/537.36"
170
+ ),
171
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
172
+ "Accept-Language": "en-US,en;q=0.9",
173
+ "Accept-Encoding": "gzip, deflate, br",
174
+ "Sec-Ch-Ua": '"Chromium";v="110", "Not A(Brand";v="24", "Google Chrome";v="110"',
175
+ "Sec-Ch-Ua-Mobile": "?0",
176
+ "Sec-Ch-Ua-Platform": '"Windows"',
177
+ "Sec-Fetch-Dest": "document",
178
+ "Sec-Fetch-Mode": "navigate",
179
+ "Sec-Fetch-Site": "none",
180
+ "Sec-Fetch-User": "?1",
181
+ "Upgrade-Insecure-Requests": "1",
182
+ },
183
+ )
184
+
185
+ FIREFOX133 = BrowserProfile(
186
+ name="firefox133",
187
+ curl_impersonate="firefox133",
188
+ h2_settings={
189
+ "HEADER_TABLE_SIZE": 65536,
190
+ "MAX_CONCURRENT_STREAMS": 1000,
191
+ "INITIAL_WINDOW_SIZE": 131072,
192
+ "MAX_FRAME_SIZE": 16384,
193
+ "MAX_HEADER_LIST_SIZE": 262144,
194
+ },
195
+ http_headers={
196
+ "User-Agent": (
197
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) "
198
+ "Gecko/20100101 Firefox/133.0"
199
+ ),
200
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
201
+ "Accept-Language": "en-US,en;q=0.5",
202
+ "Accept-Encoding": "gzip, deflate, br",
203
+ "Sec-Fetch-Dest": "document",
204
+ "Sec-Fetch-Mode": "navigate",
205
+ "Sec-Fetch-Site": "none",
206
+ "Sec-Fetch-User": "?1",
207
+ "Upgrade-Insecure-Requests": "1",
208
+ },
209
+ )
210
+
211
+ FIREFOX131 = BrowserProfile(
212
+ name="firefox131",
213
+ curl_impersonate="firefox131",
214
+ h2_settings={
215
+ "HEADER_TABLE_SIZE": 65536,
216
+ "MAX_CONCURRENT_STREAMS": 1000,
217
+ "INITIAL_WINDOW_SIZE": 131072,
218
+ "MAX_FRAME_SIZE": 16384,
219
+ "MAX_HEADER_LIST_SIZE": 262144,
220
+ },
221
+ http_headers={
222
+ "User-Agent": (
223
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) "
224
+ "Gecko/20100101 Firefox/131.0"
225
+ ),
226
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
227
+ "Accept-Language": "en-US,en;q=0.5",
228
+ "Accept-Encoding": "gzip, deflate, br",
229
+ "Sec-Fetch-Dest": "document",
230
+ "Sec-Fetch-Mode": "navigate",
231
+ "Sec-Fetch-Site": "none",
232
+ "Sec-Fetch-User": "?1",
233
+ "Upgrade-Insecure-Requests": "1",
234
+ },
235
+ )
236
+
237
+ FIREFOX120 = BrowserProfile(
238
+ name="firefox120",
239
+ curl_impersonate="firefox120",
240
+ h2_settings={
241
+ "HEADER_TABLE_SIZE": 65536,
242
+ "MAX_CONCURRENT_STREAMS": 1000,
243
+ "INITIAL_WINDOW_SIZE": 131072,
244
+ "MAX_FRAME_SIZE": 16384,
245
+ "MAX_HEADER_LIST_SIZE": 262144,
246
+ },
247
+ http_headers={
248
+ "User-Agent": (
249
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) "
250
+ "Gecko/20100101 Firefox/120.0"
251
+ ),
252
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
253
+ "Accept-Language": "en-US,en;q=0.5",
254
+ "Accept-Encoding": "gzip, deflate, br",
255
+ "Sec-Fetch-Dest": "document",
256
+ "Sec-Fetch-Mode": "navigate",
257
+ "Sec-Fetch-Site": "none",
258
+ "Sec-Fetch-User": "?1",
259
+ "Upgrade-Insecure-Requests": "1",
260
+ },
261
+ )
262
+
263
+ SAFARI18_0 = BrowserProfile(
264
+ name="safari18_0",
265
+ h2_settings={
266
+ "HEADER_TABLE_SIZE": 65536,
267
+ "MAX_CONCURRENT_STREAMS": 100,
268
+ "INITIAL_WINDOW_SIZE": 4194304,
269
+ "MAX_FRAME_SIZE": 16384,
270
+ "MAX_HEADER_LIST_SIZE": 262144,
271
+ },
272
+ http_headers={
273
+ "User-Agent": (
274
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
275
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) "
276
+ "Version/18.0 Safari/605.1.15"
277
+ ),
278
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
279
+ "Accept-Language": "en-US,en;q=0.9",
280
+ "Accept-Encoding": "gzip, deflate, br",
281
+ },
282
+ )
283
+
284
+ SAMSUNG_ANDROID = BrowserProfile(
285
+ name="samsung_android",
286
+ curl_impersonate="samsung_android",
287
+ h2_settings={
288
+ "HEADER_TABLE_SIZE": 65536,
289
+ "MAX_CONCURRENT_STREAMS": 100,
290
+ "INITIAL_WINDOW_SIZE": 65536,
291
+ "MAX_FRAME_SIZE": 16384,
292
+ "MAX_HEADER_LIST_SIZE": 262144,
293
+ },
294
+ http_headers={
295
+ "User-Agent": (
296
+ "Mozilla/5.0 (Linux; Android 13; SM-S911B) "
297
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
298
+ "SamsungBrowser/23.0 Chrome/115.0.0.0 Mobile Safari/537.36"
299
+ ),
300
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
301
+ "Accept-Language": "en-US,en;q=0.9",
302
+ "Accept-Encoding": "gzip, deflate, br",
303
+ },
304
+ )
305
+
306
+ EDGE131 = BrowserProfile(
307
+ name="edge131",
308
+ curl_impersonate="edge131",
309
+ h2_settings={
310
+ "HEADER_TABLE_SIZE": 65536,
311
+ "MAX_CONCURRENT_STREAMS": 1000,
312
+ "INITIAL_WINDOW_SIZE": 6291456,
313
+ "MAX_FRAME_SIZE": 16384,
314
+ "MAX_HEADER_LIST_SIZE": 262144,
315
+ },
316
+ http_headers={
317
+ "User-Agent": (
318
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
319
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
320
+ "Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0"
321
+ ),
322
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
323
+ "Accept-Language": "en-US,en;q=0.9",
324
+ "Accept-Encoding": "gzip, deflate, br",
325
+ "Sec-Ch-Ua": '"Chromium";v="131", "Microsoft Edge";v="131", "Not_A Brand";v="24"',
326
+ "Sec-Ch-Ua-Mobile": "?0",
327
+ "Sec-Ch-Ua-Platform": '"Windows"',
328
+ "Sec-Fetch-Dest": "document",
329
+ "Sec-Fetch-Mode": "navigate",
330
+ "Sec-Fetch-Site": "none",
331
+ "Sec-Fetch-User": "?1",
332
+ "Upgrade-Insecure-Requests": "1",
333
+ },
334
+ )
335
+
336
+ # ── Profile registry ─────────────────────────────────────────────────────────
337
+
338
+ PROFILES: dict[str, BrowserProfile] = {
339
+ "chrome131": CHROME131,
340
+ "chrome124": CHROME124,
341
+ "chrome120": CHROME120,
342
+ "chrome110": CHROME110,
343
+ "firefox133": FIREFOX133,
344
+ "firefox131": FIREFOX131,
345
+ "firefox120": FIREFOX120,
346
+ "safari18.0": SAFARI18_0,
347
+ "samsung_android": SAMSUNG_ANDROID,
348
+ "edge131": EDGE131,
349
+ }
350
+
351
+ # Aliases for curlcffi compatibility
352
+ CHROME_ALIASES = {
353
+ "chrome": CHROME131,
354
+ "chrome99": CHROME131, # closest available
355
+ "chrome100": CHROME131,
356
+ "chrome101": CHROME131,
357
+ "chrome104": CHROME131,
358
+ "chrome107": CHROME131,
359
+ "chrome110": CHROME110,
360
+ "chrome116": CHROME120,
361
+ "chrome119": CHROME120,
362
+ "chrome120": CHROME120,
363
+ "chrome123": CHROME124,
364
+ "chrome124": CHROME124,
365
+ "chrome131": CHROME131,
366
+ }
367
+
368
+ FIREFOX_ALIASES = {
369
+ "firefox": FIREFOX133,
370
+ "firefox99": FIREFOX120,
371
+ "firefox100": FIREFOX120,
372
+ "firefox102": FIREFOX120,
373
+ "firefox109": FIREFOX120,
374
+ "firefox117": FIREFOX120,
375
+ "firefox119": FIREFOX120,
376
+ "firefox120": FIREFOX120,
377
+ "firefox131": FIREFOX131,
378
+ "firefox133": FIREFOX133,
379
+ }
380
+
381
+ ALL_ALIASES = {**CHROME_ALIASES, **FIREFOX_ALIASES}
382
+
383
+
384
+ def get_profile(name: str) -> BrowserProfile:
385
+ """
386
+ Get a browser profile by name.
387
+
388
+ Supports both exact names and curlcffi-compatible aliases.
389
+
390
+ Args:
391
+ name: Profile name (e.g., "chrome131", "firefox120", "chrome")
392
+
393
+ Returns:
394
+ BrowserProfile instance
395
+
396
+ Raises:
397
+ ValueError: If profile name is not recognized
398
+ """
399
+ # Normalize: strip "chrome" prefix variations
400
+ normalized = name.lower().strip()
401
+
402
+ # Check direct match first
403
+ if normalized in PROFILES:
404
+ return PROFILES[normalized]
405
+
406
+ # Check aliases
407
+ if normalized in ALL_ALIASES:
408
+ return ALL_ALIASES[normalized]
409
+
410
+ # Try fuzzy match
411
+ for profile_name, profile in PROFILES.items():
412
+ if profile_name in normalized or normalized in profile_name:
413
+ return profile
414
+
415
+ available = ", ".join(sorted(set(list(PROFILES.keys()) + list(ALL_ALIASES.keys()))))
416
+ raise ValueError(
417
+ f"Unknown browser profile: {name!r}. "
418
+ f"Available profiles: {available}"
419
+ )
420
+
421
+
422
+ def list_profiles() -> list[str]:
423
+ """List all available profile names."""
424
+ return sorted(set(list(PROFILES.keys()) + list(ALL_ALIASES.keys())))