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/README.md +244 -0
- bas/__init__.py +97 -0
- bas/_curl.py +393 -0
- bas/async_session.py +352 -0
- bas/cookies.py +643 -0
- bas/curl_parser.py +267 -0
- bas/impersonate.py +424 -0
- bas/models.py +339 -0
- bas/session.py +648 -0
- bas/simple_session.py +299 -0
- bas/tests/__init__.py +0 -0
- bas/tests/test_cookies.py +778 -0
- bas/tests/test_curl_parser.py +172 -0
- bas/tests/test_real_request.py +142 -0
- bas/tests/test_spaceshooter.py +213 -0
- bas_http-1.0.0.dist-info/METADATA +25 -0
- bas_http-1.0.0.dist-info/RECORD +19 -0
- bas_http-1.0.0.dist-info/WHEEL +5 -0
- bas_http-1.0.0.dist-info/top_level.txt +1 -0
bas/cookies.py
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
"""
|
|
2
|
+
RFC 6265 compliant CookieJar.
|
|
3
|
+
|
|
4
|
+
This module fixes the major cookie issues in curlcffi:
|
|
5
|
+
1. Cookies lost during redirects (#55)
|
|
6
|
+
2. Cookies not accumulating across session requests (#582)
|
|
7
|
+
3. Need for manual cookie header management
|
|
8
|
+
4. Improper domain/path matching
|
|
9
|
+
|
|
10
|
+
Key design decisions:
|
|
11
|
+
- Pure Python cookie parsing (no dependency on curl's internal cookie engine)
|
|
12
|
+
- Per-domain storage with RFC 6265 domain matching
|
|
13
|
+
- Thread-safe with threading.Lock
|
|
14
|
+
- Cookie expiration handled correctly
|
|
15
|
+
- Secure, HttpOnly, SameSite attribute support
|
|
16
|
+
- Netscape file format import/export for compatibility
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from http.cookiejar import CookieJar as _stdlib_jar
|
|
25
|
+
from typing import Optional
|
|
26
|
+
from urllib.parse import urlparse, urlunparse, quote, unquote
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Cookie:
|
|
30
|
+
"""
|
|
31
|
+
Represents a single HTTP cookie.
|
|
32
|
+
|
|
33
|
+
Attributes mirror the key properties in RFC 6265 Section 5.4.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
__slots__ = (
|
|
37
|
+
"name", "value", "domain", "path", "expires",
|
|
38
|
+
"max_age", "secure", "http_only", "same_site",
|
|
39
|
+
"creation_time", "port", "port_specified",
|
|
40
|
+
"domain_specified", "domain_initial_dot",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
name: str,
|
|
46
|
+
value: str,
|
|
47
|
+
domain: str = "",
|
|
48
|
+
path: str = "/",
|
|
49
|
+
expires: Optional[float] = None,
|
|
50
|
+
max_age: Optional[int] = None,
|
|
51
|
+
secure: bool = False,
|
|
52
|
+
http_only: bool = False,
|
|
53
|
+
same_site: Optional[str] = None,
|
|
54
|
+
port: Optional[str] = None,
|
|
55
|
+
port_specified: bool = False,
|
|
56
|
+
domain_specified: bool = False,
|
|
57
|
+
domain_initial_dot: bool = False,
|
|
58
|
+
):
|
|
59
|
+
self.name = name
|
|
60
|
+
self.value = value
|
|
61
|
+
self.domain_initial_dot = domain.startswith(".") or domain_initial_dot
|
|
62
|
+
self.domain = domain.lower().strip(".")
|
|
63
|
+
self.path = path or "/"
|
|
64
|
+
self.expires = expires
|
|
65
|
+
self.max_age = max_age
|
|
66
|
+
self.secure = secure
|
|
67
|
+
self.http_only = http_only
|
|
68
|
+
self.same_site = same_site
|
|
69
|
+
self.creation_time = time.time()
|
|
70
|
+
self.port = port
|
|
71
|
+
self.port_specified = port_specified
|
|
72
|
+
self.domain_specified = domain_specified
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def effective_expires(self) -> Optional[float]:
|
|
76
|
+
"""Compute actual expiry time considering max_age vs expires."""
|
|
77
|
+
if self.max_age is not None:
|
|
78
|
+
return self.creation_time + self.max_age
|
|
79
|
+
return self.expires
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def is_expired(self) -> bool:
|
|
83
|
+
"""Check if the cookie has expired."""
|
|
84
|
+
exp = self.effective_expires
|
|
85
|
+
if exp is None:
|
|
86
|
+
return False
|
|
87
|
+
return time.time() > exp
|
|
88
|
+
|
|
89
|
+
def matches_domain(self, request_domain: str) -> bool:
|
|
90
|
+
"""
|
|
91
|
+
RFC 6265 Section 5.1.3 — Domain Matching.
|
|
92
|
+
|
|
93
|
+
A request-domain D matches a cookie-domain C if:
|
|
94
|
+
- The string is identical to C, OR
|
|
95
|
+
- The string is a rightmost match of C (suffix match)
|
|
96
|
+
AND C's first character is '.', OR
|
|
97
|
+
- The string is a rightmost match of C
|
|
98
|
+
AND C does NOT start with '.'
|
|
99
|
+
AND the request-domain is an IP address.
|
|
100
|
+
"""
|
|
101
|
+
rd = request_domain.lower()
|
|
102
|
+
cd = self.domain
|
|
103
|
+
|
|
104
|
+
if not cd:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
# Exact match
|
|
108
|
+
if rd == cd:
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
# If cookie domain starts with '.', it must be a suffix match
|
|
112
|
+
if self.domain_initial_dot:
|
|
113
|
+
return rd.endswith(cd) and rd[-len(cd) - 1] == "."
|
|
114
|
+
|
|
115
|
+
# If cookie domain doesn't start with '.', it must be an exact or
|
|
116
|
+
# suffix match where the request domain ends with the cookie domain
|
|
117
|
+
# and the character before is a '.' (to prevent "evil.com" matching "e.com")
|
|
118
|
+
if rd.endswith(cd):
|
|
119
|
+
if len(rd) == len(cd):
|
|
120
|
+
return True # exact match
|
|
121
|
+
return rd[-(len(cd) + 1)] == "."
|
|
122
|
+
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
def matches_path(self, request_path: str) -> bool:
|
|
126
|
+
"""
|
|
127
|
+
RFC 6265 Section 5.1.4 — Path Matching.
|
|
128
|
+
|
|
129
|
+
A request-path P matches cookie-path C if:
|
|
130
|
+
- The string is identical to C, OR
|
|
131
|
+
- The string starts with C and the last character of C is '/', OR
|
|
132
|
+
- The string starts with C, the next character in the string
|
|
133
|
+
is either '/' or end-of-string.
|
|
134
|
+
"""
|
|
135
|
+
cp = self.path
|
|
136
|
+
rp = request_path
|
|
137
|
+
|
|
138
|
+
if rp == cp:
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
if cp == "/":
|
|
142
|
+
return True # "/" matches everything
|
|
143
|
+
|
|
144
|
+
if rp.startswith(cp):
|
|
145
|
+
if len(rp) == len(cp):
|
|
146
|
+
return True # exact match
|
|
147
|
+
if cp.endswith("/"):
|
|
148
|
+
return True
|
|
149
|
+
return rp[len(cp)] in ("/", "?", "#", "")
|
|
150
|
+
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
def to_header(self) -> str:
|
|
154
|
+
"""Serialize cookie as a Cookie header value."""
|
|
155
|
+
return f"{self.name}={self.value}"
|
|
156
|
+
|
|
157
|
+
def to_set_cookie_header(self) -> str:
|
|
158
|
+
"""Serialize as a Set-Cookie header."""
|
|
159
|
+
parts = [f"{self.name}={self.value}"]
|
|
160
|
+
if self.domain:
|
|
161
|
+
parts.append(f"Domain={self.domain}")
|
|
162
|
+
if self.path:
|
|
163
|
+
parts.append(f"Path={self.path}")
|
|
164
|
+
if self.expires is not None:
|
|
165
|
+
parts.append(f"Expires={_format_cookie_time(self.expires)}")
|
|
166
|
+
if self.max_age is not None:
|
|
167
|
+
parts.append(f"Max-Age={self.max_age}")
|
|
168
|
+
if self.secure:
|
|
169
|
+
parts.append("Secure")
|
|
170
|
+
if self.http_only:
|
|
171
|
+
parts.append("HttpOnly")
|
|
172
|
+
if self.same_site:
|
|
173
|
+
parts.append(f"SameSite={self.same_site}")
|
|
174
|
+
return "; ".join(parts)
|
|
175
|
+
|
|
176
|
+
def to_dict(self) -> dict:
|
|
177
|
+
"""Serialize cookie to a JSON-safe dict."""
|
|
178
|
+
return {
|
|
179
|
+
"name": self.name,
|
|
180
|
+
"value": self.value,
|
|
181
|
+
"domain": self.domain,
|
|
182
|
+
"path": self.path,
|
|
183
|
+
"expires": self.expires,
|
|
184
|
+
"max_age": self.max_age,
|
|
185
|
+
"secure": self.secure,
|
|
186
|
+
"http_only": self.http_only,
|
|
187
|
+
"same_site": self.same_site,
|
|
188
|
+
"creation_time": self.creation_time,
|
|
189
|
+
"port": self.port,
|
|
190
|
+
"port_specified": self.port_specified,
|
|
191
|
+
"domain_specified": self.domain_specified,
|
|
192
|
+
"domain_initial_dot": self.domain_initial_dot,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
def from_dict(cls, data: dict) -> "Cookie":
|
|
197
|
+
"""Deserialize a cookie from a dict."""
|
|
198
|
+
# creation_time is set in __init__, extract it separately
|
|
199
|
+
creation_time = data.pop("creation_time", None)
|
|
200
|
+
c = cls(**data)
|
|
201
|
+
if creation_time is not None:
|
|
202
|
+
c.creation_time = creation_time
|
|
203
|
+
return c
|
|
204
|
+
|
|
205
|
+
def __repr__(self) -> str:
|
|
206
|
+
return (
|
|
207
|
+
f"Cookie(name={self.name!r}, value={self.value!r}, "
|
|
208
|
+
f"domain={self.domain!r}, path={self.path!r}, "
|
|
209
|
+
f"secure={self.secure}, http_only={self.http_only})"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def __eq__(self, other) -> bool:
|
|
213
|
+
if not isinstance(other, Cookie):
|
|
214
|
+
return NotImplemented
|
|
215
|
+
return (
|
|
216
|
+
self.name == other.name
|
|
217
|
+
and self.domain == other.domain
|
|
218
|
+
and self.path == other.path
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def __hash__(self) -> int:
|
|
222
|
+
return hash((self.name, self.domain, self.path))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class CookieJar:
|
|
226
|
+
"""
|
|
227
|
+
Thread-safe, RFC 6265 compliant cookie jar.
|
|
228
|
+
|
|
229
|
+
This is the heart of bas's cookie handling. Unlike curlcffi's
|
|
230
|
+
approach (which relies on libcurl's internal cookie engine and loses
|
|
231
|
+
cookies during redirects), bas manages cookies entirely in Python.
|
|
232
|
+
|
|
233
|
+
This means:
|
|
234
|
+
- Cookies accumulate correctly across ALL requests in a session
|
|
235
|
+
- Cookies are inherited during redirects (even cross-domain)
|
|
236
|
+
- No manual cookie header construction needed
|
|
237
|
+
- Full control over cookie serialization and persistence
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
def __init__(self):
|
|
241
|
+
self._cookies: list[Cookie] = []
|
|
242
|
+
self._lock = threading.Lock()
|
|
243
|
+
|
|
244
|
+
def __len__(self) -> int:
|
|
245
|
+
with self._lock:
|
|
246
|
+
return len(self._cookies)
|
|
247
|
+
|
|
248
|
+
def __iter__(self):
|
|
249
|
+
with self._lock:
|
|
250
|
+
return iter(list(self._cookies))
|
|
251
|
+
|
|
252
|
+
def __contains__(self, cookie: Cookie) -> bool:
|
|
253
|
+
with self._lock:
|
|
254
|
+
return cookie in self._cookies
|
|
255
|
+
|
|
256
|
+
def clear(self) -> None:
|
|
257
|
+
"""Remove all cookies."""
|
|
258
|
+
with self._lock:
|
|
259
|
+
self._cookies.clear()
|
|
260
|
+
|
|
261
|
+
def add(self, cookie: Cookie) -> None:
|
|
262
|
+
"""
|
|
263
|
+
Add a cookie to the jar, replacing any existing cookie
|
|
264
|
+
with the same (name, domain, path).
|
|
265
|
+
"""
|
|
266
|
+
with self._lock:
|
|
267
|
+
# Remove existing cookie with same key
|
|
268
|
+
self._cookies = [
|
|
269
|
+
c for c in self._cookies
|
|
270
|
+
if not (c.name == cookie.name and c.domain == cookie.domain and c.path == cookie.path)
|
|
271
|
+
]
|
|
272
|
+
if not cookie.is_expired:
|
|
273
|
+
self._cookies.append(cookie)
|
|
274
|
+
|
|
275
|
+
def remove(self, cookie: Cookie) -> None:
|
|
276
|
+
"""Remove a specific cookie."""
|
|
277
|
+
with self._lock:
|
|
278
|
+
self._cookies = [
|
|
279
|
+
c for c in self._cookies
|
|
280
|
+
if not (c.name == cookie.name and c.domain == cookie.domain and c.path == cookie.path)
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
def update(self, other: "CookieJar") -> None:
|
|
284
|
+
"""Merge all cookies from another jar into this one."""
|
|
285
|
+
with self._lock:
|
|
286
|
+
for cookie in other:
|
|
287
|
+
self.add(cookie)
|
|
288
|
+
|
|
289
|
+
def get(
|
|
290
|
+
self,
|
|
291
|
+
name: str,
|
|
292
|
+
domain: Optional[str] = None,
|
|
293
|
+
path: Optional[str] = None,
|
|
294
|
+
) -> Optional[Cookie]:
|
|
295
|
+
"""Get a specific cookie by name, optionally filtered by domain/path."""
|
|
296
|
+
with self._lock:
|
|
297
|
+
for cookie in reversed(self._cookies): # most recent first
|
|
298
|
+
if cookie.name == name:
|
|
299
|
+
if domain and not cookie.matches_domain(domain):
|
|
300
|
+
continue
|
|
301
|
+
if path and not cookie.matches_path(path):
|
|
302
|
+
continue
|
|
303
|
+
if not cookie.is_expired:
|
|
304
|
+
return cookie
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
def get_all(
|
|
308
|
+
self,
|
|
309
|
+
name: Optional[str] = None,
|
|
310
|
+
domain: Optional[str] = None,
|
|
311
|
+
path: Optional[str] = None,
|
|
312
|
+
) -> list[Cookie]:
|
|
313
|
+
"""Get all matching cookies."""
|
|
314
|
+
with self._lock:
|
|
315
|
+
result = []
|
|
316
|
+
for cookie in self._cookies:
|
|
317
|
+
if cookie.is_expired:
|
|
318
|
+
continue
|
|
319
|
+
if name and cookie.name != name:
|
|
320
|
+
continue
|
|
321
|
+
if domain and not cookie.matches_domain(domain):
|
|
322
|
+
continue
|
|
323
|
+
if path and not cookie.matches_path(path):
|
|
324
|
+
continue
|
|
325
|
+
result.append(cookie)
|
|
326
|
+
return result
|
|
327
|
+
|
|
328
|
+
def match(self, url: str) -> list[Cookie]:
|
|
329
|
+
"""
|
|
330
|
+
Get all cookies matching a given URL.
|
|
331
|
+
|
|
332
|
+
This is the primary method for determining which cookies to
|
|
333
|
+
send with a request. It performs full RFC 6265 matching.
|
|
334
|
+
"""
|
|
335
|
+
parsed = urlparse(url)
|
|
336
|
+
domain = parsed.hostname or ""
|
|
337
|
+
path = parsed.path or "/"
|
|
338
|
+
is_secure = parsed.scheme == "https"
|
|
339
|
+
|
|
340
|
+
with self._lock:
|
|
341
|
+
result = []
|
|
342
|
+
expired_names = []
|
|
343
|
+
|
|
344
|
+
for cookie in self._cookies:
|
|
345
|
+
if cookie.is_expired:
|
|
346
|
+
expired_names.append(cookie)
|
|
347
|
+
continue
|
|
348
|
+
|
|
349
|
+
if not cookie.matches_domain(domain):
|
|
350
|
+
continue
|
|
351
|
+
if not cookie.matches_path(path):
|
|
352
|
+
continue
|
|
353
|
+
if cookie.secure and not is_secure:
|
|
354
|
+
continue
|
|
355
|
+
result.append(cookie)
|
|
356
|
+
|
|
357
|
+
# Clean up expired cookies
|
|
358
|
+
for c in expired_names:
|
|
359
|
+
try:
|
|
360
|
+
self._cookies.remove(c)
|
|
361
|
+
except ValueError:
|
|
362
|
+
pass
|
|
363
|
+
|
|
364
|
+
return result
|
|
365
|
+
|
|
366
|
+
def parse_set_cookie(
|
|
367
|
+
self,
|
|
368
|
+
set_cookie_header: str,
|
|
369
|
+
request_url: str,
|
|
370
|
+
) -> Optional[Cookie]:
|
|
371
|
+
"""
|
|
372
|
+
Parse a Set-Cookie header and add the cookie to the jar.
|
|
373
|
+
|
|
374
|
+
This is where the magic happens — we parse the Set-Cookie header
|
|
375
|
+
ourselves instead of relying on curl's broken cookie engine.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
set_cookie_header: The raw Set-Cookie header value
|
|
379
|
+
request_url: The URL that set this cookie (used for default domain/path)
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
The parsed Cookie, or None if the cookie should be deleted
|
|
383
|
+
"""
|
|
384
|
+
if not set_cookie_header:
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
parsed_request = urlparse(request_url)
|
|
388
|
+
default_domain = parsed_request.hostname or ""
|
|
389
|
+
default_path = _guess_cookie_path(parsed_request.path or "/")
|
|
390
|
+
|
|
391
|
+
# Parse name=value and attributes
|
|
392
|
+
parts = [p.strip() for p in set_cookie_header.split(";")]
|
|
393
|
+
if not parts:
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
# First part is name=value
|
|
397
|
+
name_value = parts[0]
|
|
398
|
+
eq_idx = name_value.find("=")
|
|
399
|
+
if eq_idx == -1:
|
|
400
|
+
name = unquote(name_value.strip())
|
|
401
|
+
value = ""
|
|
402
|
+
else:
|
|
403
|
+
name = unquote(name_value[:eq_idx].strip())
|
|
404
|
+
value = unquote(name_value[eq_idx + 1:].strip())
|
|
405
|
+
|
|
406
|
+
if not name:
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
# Parse attributes
|
|
410
|
+
cookie = Cookie(
|
|
411
|
+
name=name,
|
|
412
|
+
value=value,
|
|
413
|
+
domain=default_domain,
|
|
414
|
+
path=default_path,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
for part in parts[1:]:
|
|
418
|
+
part = part.strip()
|
|
419
|
+
eq_idx = part.find("=")
|
|
420
|
+
|
|
421
|
+
if eq_idx == -1:
|
|
422
|
+
attr_name = part.lower()
|
|
423
|
+
attr_value = ""
|
|
424
|
+
else:
|
|
425
|
+
attr_name = part[:eq_idx].strip().lower()
|
|
426
|
+
attr_value = part[eq_idx + 1:].strip()
|
|
427
|
+
|
|
428
|
+
if attr_name == "domain":
|
|
429
|
+
d = attr_value.lower().strip(".")
|
|
430
|
+
if d:
|
|
431
|
+
cookie.domain = d
|
|
432
|
+
cookie.domain_specified = True
|
|
433
|
+
cookie.domain_initial_dot = attr_value.startswith(".")
|
|
434
|
+
elif attr_name == "path":
|
|
435
|
+
if attr_value and attr_value.startswith("/"):
|
|
436
|
+
cookie.path = attr_value
|
|
437
|
+
else:
|
|
438
|
+
cookie.path = default_path
|
|
439
|
+
elif attr_name == "expires":
|
|
440
|
+
try:
|
|
441
|
+
cookie.expires = _parse_cookie_time(attr_value)
|
|
442
|
+
except (ValueError, TypeError):
|
|
443
|
+
pass
|
|
444
|
+
elif attr_name == "max-age":
|
|
445
|
+
try:
|
|
446
|
+
cookie.max_age = int(attr_value)
|
|
447
|
+
except (ValueError, TypeError):
|
|
448
|
+
pass
|
|
449
|
+
elif attr_name == "secure":
|
|
450
|
+
cookie.secure = True
|
|
451
|
+
elif attr_name == "httponly":
|
|
452
|
+
cookie.http_only = True
|
|
453
|
+
elif attr_name == "samesite":
|
|
454
|
+
cookie.same_site = attr_value.capitalize()
|
|
455
|
+
elif attr_name == "port":
|
|
456
|
+
cookie.port = attr_value
|
|
457
|
+
cookie.port_specified = bool(attr_value)
|
|
458
|
+
|
|
459
|
+
# Add to jar
|
|
460
|
+
self.add(cookie)
|
|
461
|
+
return cookie
|
|
462
|
+
|
|
463
|
+
def parse_set_cookie_headers(
|
|
464
|
+
self,
|
|
465
|
+
headers: dict[str, str],
|
|
466
|
+
request_url: str,
|
|
467
|
+
) -> list[Cookie]:
|
|
468
|
+
"""
|
|
469
|
+
Parse ALL Set-Cookie headers from a response.
|
|
470
|
+
|
|
471
|
+
Handles both single-value and multi-value Set-Cookie headers.
|
|
472
|
+
"""
|
|
473
|
+
cookies = []
|
|
474
|
+
|
|
475
|
+
# Handle both dict and list-of-tuples header formats
|
|
476
|
+
set_cookie_values = []
|
|
477
|
+
for key, value in headers.items():
|
|
478
|
+
if key.lower() == "set-cookie":
|
|
479
|
+
if isinstance(value, list):
|
|
480
|
+
set_cookie_values.extend(value)
|
|
481
|
+
else:
|
|
482
|
+
set_cookie_values.append(value)
|
|
483
|
+
|
|
484
|
+
for sc in set_cookie_values:
|
|
485
|
+
cookie = self.parse_set_cookie(sc, request_url)
|
|
486
|
+
if cookie:
|
|
487
|
+
cookies.append(cookie)
|
|
488
|
+
|
|
489
|
+
return cookies
|
|
490
|
+
|
|
491
|
+
def to_header(self, url: str) -> str:
|
|
492
|
+
"""
|
|
493
|
+
Build a Cookie header value for the given URL.
|
|
494
|
+
|
|
495
|
+
This is what gets sent in the HTTP request. It returns ALL matching
|
|
496
|
+
cookies in the correct format, eliminating the need for users to
|
|
497
|
+
manually construct cookie headers.
|
|
498
|
+
"""
|
|
499
|
+
cookies = self.match(url)
|
|
500
|
+
if not cookies:
|
|
501
|
+
return ""
|
|
502
|
+
return "; ".join(f"{c.name}={c.value}" for c in cookies)
|
|
503
|
+
|
|
504
|
+
def to_header_dict(self, url: str) -> dict[str, str]:
|
|
505
|
+
"""Return a dict suitable for merging into request headers."""
|
|
506
|
+
header = self.to_header(url)
|
|
507
|
+
if header:
|
|
508
|
+
return {"Cookie": header}
|
|
509
|
+
return {}
|
|
510
|
+
|
|
511
|
+
def save_json(self, filepath: str) -> None:
|
|
512
|
+
"""Save cookies to a JSON file."""
|
|
513
|
+
with self._lock:
|
|
514
|
+
data = [c.to_dict() for c in self._cookies if not c.is_expired]
|
|
515
|
+
with open(filepath, "w") as f:
|
|
516
|
+
json.dump(data, f, indent=2)
|
|
517
|
+
|
|
518
|
+
def load_json(self, filepath: str) -> None:
|
|
519
|
+
"""Load cookies from a JSON file."""
|
|
520
|
+
try:
|
|
521
|
+
with open(filepath, "r") as f:
|
|
522
|
+
data = json.load(f)
|
|
523
|
+
with self._lock:
|
|
524
|
+
self._cookies.clear()
|
|
525
|
+
for item in data:
|
|
526
|
+
c = Cookie.from_dict(item)
|
|
527
|
+
if not c.is_expired:
|
|
528
|
+
self._cookies.append(c)
|
|
529
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
530
|
+
pass
|
|
531
|
+
|
|
532
|
+
def save_netscape(self, filepath: str) -> None:
|
|
533
|
+
"""Save cookies in Netscape/Mozilla cookie file format."""
|
|
534
|
+
with self._lock:
|
|
535
|
+
lines = ["# Netscape HTTP Cookie File\n"]
|
|
536
|
+
for c in self._cookies:
|
|
537
|
+
if c.is_expired:
|
|
538
|
+
continue
|
|
539
|
+
domain = ("." + c.domain) if not c.domain_initial_dot and not c.domain.startswith(".") else c.domain
|
|
540
|
+
flag = "TRUE" if c.domain_initial_dot else "FALSE"
|
|
541
|
+
secure = "TRUE" if c.secure else "FALSE"
|
|
542
|
+
expires = str(int(c.expires)) if c.expires else "0"
|
|
543
|
+
lines.append(
|
|
544
|
+
f"{domain}\t{flag}\t{c.path}\t{secure}\t{expires}\t{c.name}\t{c.value}"
|
|
545
|
+
)
|
|
546
|
+
with open(filepath, "w") as f:
|
|
547
|
+
f.write("\n".join(lines) + "\n")
|
|
548
|
+
|
|
549
|
+
def load_netscape(self, filepath: str) -> None:
|
|
550
|
+
"""Load cookies from a Netscape/Mozilla cookie file."""
|
|
551
|
+
try:
|
|
552
|
+
with open(filepath, "r") as f:
|
|
553
|
+
for line in f:
|
|
554
|
+
line = line.strip()
|
|
555
|
+
if not line or line.startswith("#"):
|
|
556
|
+
continue
|
|
557
|
+
parts = line.split("\t")
|
|
558
|
+
if len(parts) < 7:
|
|
559
|
+
continue
|
|
560
|
+
domain, _, path, secure, expires, name, value = parts[:7]
|
|
561
|
+
initial_dot = domain.startswith(".")
|
|
562
|
+
cookie = Cookie(
|
|
563
|
+
name=name,
|
|
564
|
+
value=value,
|
|
565
|
+
domain=domain.strip("."),
|
|
566
|
+
path=path,
|
|
567
|
+
expires=float(expires) if expires != "0" else None,
|
|
568
|
+
secure=secure.upper() == "TRUE",
|
|
569
|
+
domain_initial_dot=initial_dot,
|
|
570
|
+
domain_specified=True,
|
|
571
|
+
)
|
|
572
|
+
self.add(cookie)
|
|
573
|
+
except FileNotFoundError:
|
|
574
|
+
pass
|
|
575
|
+
|
|
576
|
+
def get_dict(self, url: str) -> dict[str, str]:
|
|
577
|
+
"""Return cookies as a simple {name: value} dict for the given URL."""
|
|
578
|
+
return {c.name: c.value for c in self.match(url)}
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
# ── Helper functions ─────────────────────────────────────────────────────────
|
|
582
|
+
|
|
583
|
+
def _format_cookie_time(timestamp: float) -> str:
|
|
584
|
+
"""Format a timestamp as an HTTP date string for Set-Cookie."""
|
|
585
|
+
dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
|
|
586
|
+
return dt.strftime("%a, %d %b %Y %H:%M:%S GMT")
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
_COOKIE_MONTHS = {
|
|
590
|
+
"jan": 1, "feb": 2, "mar": 3, "apr": 4,
|
|
591
|
+
"may": 5, "jun": 6, "jul": 7, "aug": 8,
|
|
592
|
+
"sep": 9, "oct": 10, "nov": 11, "dec": 12,
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _parse_cookie_time(time_str: str) -> Optional[float]:
|
|
597
|
+
"""
|
|
598
|
+
Parse various date formats found in Set-Cookie Expires headers.
|
|
599
|
+
|
|
600
|
+
Supports:
|
|
601
|
+
- RFC 1123: "Thu, 01 Dec 2025 16:00:00 GMT"
|
|
602
|
+
- RFC 1036: "Thursday, 01-Dec-25 16:00:00 GMT"
|
|
603
|
+
- Asctime: "Dec 1 16:00:00 2025 GMT"
|
|
604
|
+
- Simple: "Thu, 01 Dec 2025 16:00:00"
|
|
605
|
+
"""
|
|
606
|
+
time_str = time_str.strip()
|
|
607
|
+
|
|
608
|
+
# Try common formats
|
|
609
|
+
formats = [
|
|
610
|
+
"%a, %d %b %Y %H:%M:%S %Z",
|
|
611
|
+
"%a, %d-%b-%Y %H:%M:%S %Z",
|
|
612
|
+
"%a, %d %b %Y %H:%M:%S",
|
|
613
|
+
"%A, %d-%b-%y %H:%M:%S %Z",
|
|
614
|
+
"%a %b %d %H:%M:%S %Y",
|
|
615
|
+
"%a %b %d %H:%M:%S %Y",
|
|
616
|
+
]
|
|
617
|
+
|
|
618
|
+
for fmt in formats:
|
|
619
|
+
try:
|
|
620
|
+
dt = datetime.strptime(time_str, fmt)
|
|
621
|
+
return dt.timestamp()
|
|
622
|
+
except ValueError:
|
|
623
|
+
continue
|
|
624
|
+
|
|
625
|
+
return None
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _guess_cookie_path(request_path: str) -> str:
|
|
629
|
+
"""
|
|
630
|
+
Guess a sensible default cookie path from the request path.
|
|
631
|
+
|
|
632
|
+
RFC 6265 Section 5.1.4 says the default path should be the
|
|
633
|
+
"longest directory path" — i.e., everything up to the last '/'.
|
|
634
|
+
"""
|
|
635
|
+
if not request_path or not request_path.startswith("/"):
|
|
636
|
+
return "/"
|
|
637
|
+
|
|
638
|
+
# Find the last '/' that isn't the first character
|
|
639
|
+
last_slash = request_path.rfind("/")
|
|
640
|
+
if last_slash <= 0:
|
|
641
|
+
return "/"
|
|
642
|
+
|
|
643
|
+
return request_path[:last_slash]
|