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/session.py
ADDED
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Session-based HTTP client with proper cookie management.
|
|
3
|
+
|
|
4
|
+
This is the main public API of bas. It provides a requests-like
|
|
5
|
+
interface while fixing ALL of curlcffi's cookie bugs:
|
|
6
|
+
|
|
7
|
+
Fix #1: Cookies accumulate correctly across requests in a session
|
|
8
|
+
Fix #2: Cookies are inherited during redirects (even cross-domain)
|
|
9
|
+
Fix #3: No manual cookie header construction needed
|
|
10
|
+
Fix #4: Proper Set-Cookie parsing with all attributes
|
|
11
|
+
Fix #5: Thread-safe cookie operations
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Optional, Union
|
|
18
|
+
from urllib.parse import urlparse, urlencode, urljoin, quote
|
|
19
|
+
|
|
20
|
+
from .cookies import Cookie, CookieJar
|
|
21
|
+
from .impersonate import BrowserProfile, get_profile, CHROME131, ALL_ALIASES, PROFILES
|
|
22
|
+
from .models import (
|
|
23
|
+
Headers,
|
|
24
|
+
PreparedRequest,
|
|
25
|
+
Response,
|
|
26
|
+
HTTPError,
|
|
27
|
+
ConnectionError,
|
|
28
|
+
Timeout,
|
|
29
|
+
TooManyRedirects,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# Optional: try to use low-level curl bindings if available
|
|
33
|
+
try:
|
|
34
|
+
from ._curl import (
|
|
35
|
+
CurlEasy,
|
|
36
|
+
CurlSList,
|
|
37
|
+
curl_version,
|
|
38
|
+
CURLOPT_URL,
|
|
39
|
+
CURLOPT_HTTPGET,
|
|
40
|
+
CURLOPT_POST,
|
|
41
|
+
CURLOPT_CUSTOMREQUEST,
|
|
42
|
+
CURLOPT_HTTPHEADER,
|
|
43
|
+
CURLOPT_COOKIE,
|
|
44
|
+
CURLOPT_COOKIELIST,
|
|
45
|
+
CURLOPT_FOLLOWLOCATION,
|
|
46
|
+
CURLOPT_MAXREDIRS,
|
|
47
|
+
CURLOPT_SSL_VERIFYPEER,
|
|
48
|
+
CURLOPT_SSL_VERIFYHOST,
|
|
49
|
+
CURLOPT_TIMEOUT,
|
|
50
|
+
CURLOPT_CONNECTTIMEOUT,
|
|
51
|
+
CURLOPT_USERAGENT,
|
|
52
|
+
CURLOPT_REFERER,
|
|
53
|
+
CURLOPT_ENCODING,
|
|
54
|
+
CURLOPT_PROXY,
|
|
55
|
+
CURLOPT_PROXYPORT,
|
|
56
|
+
CURLOPT_PROXYTYPE,
|
|
57
|
+
CURLOPT_WRITEFUNCTION,
|
|
58
|
+
CURLOPT_WRITEDATA,
|
|
59
|
+
CURLOPT_HEADERFUNCTION,
|
|
60
|
+
CURLOPT_HEADERDATA,
|
|
61
|
+
CURLOPT_POSTFIELDS,
|
|
62
|
+
CURLOPT_HTTP_VERSION,
|
|
63
|
+
CURLINFO_RESPONSE_CODE,
|
|
64
|
+
CURLINFO_EFFECTIVE_URL,
|
|
65
|
+
CURLINFO_CONTENT_TYPE,
|
|
66
|
+
CURLINFO_REDIRECT_URL,
|
|
67
|
+
CURLINFO_HEADER_SIZE,
|
|
68
|
+
CURLINFO_TOTAL_TIME,
|
|
69
|
+
CURLINFO_PRIMARY_IP,
|
|
70
|
+
CURLINFO_PRIMARY_PORT,
|
|
71
|
+
CURLINFO_HTTP_VERSION,
|
|
72
|
+
CURLINFO_SSL_VERIFYRESULT,
|
|
73
|
+
CURL_HTTP_VERSION_2TLS,
|
|
74
|
+
CURLPROXY_HTTP,
|
|
75
|
+
CURLPROXY_SOCKS4,
|
|
76
|
+
CURLPROXY_SOCKS5,
|
|
77
|
+
CURLPROXY_SOCKS4A,
|
|
78
|
+
CURLPROXY_SOCKS5_HOSTNAME,
|
|
79
|
+
CURL_OK,
|
|
80
|
+
WRITEFUNC,
|
|
81
|
+
HEADERFUNC,
|
|
82
|
+
)
|
|
83
|
+
HAS_CURL = True
|
|
84
|
+
except (ImportError, OSError):
|
|
85
|
+
HAS_CURL = False
|
|
86
|
+
|
|
87
|
+
import ctypes
|
|
88
|
+
from ctypes import c_int, c_void_p, c_char_p
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Session:
|
|
92
|
+
"""
|
|
93
|
+
A persistent HTTP session with automatic cookie management.
|
|
94
|
+
|
|
95
|
+
Key improvements over curlcffi.Session:
|
|
96
|
+
- Cookies ALWAYS accumulate across requests (fixes #582)
|
|
97
|
+
- Cookies are inherited during redirects (fixes #55)
|
|
98
|
+
- Automatic Cookie header injection (no manual construction)
|
|
99
|
+
- Thread-safe cookie operations
|
|
100
|
+
- Support for both libcurl and stdlib fallback
|
|
101
|
+
- Better error messages and timeout handling
|
|
102
|
+
|
|
103
|
+
Usage:
|
|
104
|
+
>>> with Session() as s:
|
|
105
|
+
... r = s.get("https://example.com")
|
|
106
|
+
... # Cookies from this response are automatically stored
|
|
107
|
+
... r = s.get("https://example.com/dashboard")
|
|
108
|
+
... # Cookies are automatically sent with this request
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
impersonate: Optional[str] = None,
|
|
114
|
+
profile: Optional[BrowserProfile] = None,
|
|
115
|
+
proxies: Optional[dict] = None,
|
|
116
|
+
verify: bool = True,
|
|
117
|
+
timeout: Optional[float] = None,
|
|
118
|
+
cookies: Optional[dict] = None,
|
|
119
|
+
headers: Optional[dict] = None,
|
|
120
|
+
allow_redirects: bool = True,
|
|
121
|
+
max_redirects: int = 20,
|
|
122
|
+
cert: Optional[Union[str, tuple]] = None,
|
|
123
|
+
trust_env: bool = True,
|
|
124
|
+
):
|
|
125
|
+
"""
|
|
126
|
+
Initialize a new session.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
impersonate: Browser to impersonate (e.g., "chrome131")
|
|
130
|
+
profile: Browser profile object (overrides impersonate)
|
|
131
|
+
proxies: Proxy dict with "http" and/or "https" keys
|
|
132
|
+
verify: Whether to verify SSL certificates
|
|
133
|
+
timeout: Default timeout for all requests in seconds
|
|
134
|
+
cookies: Pre-set cookies as {name: value} dict
|
|
135
|
+
headers: Default headers for all requests
|
|
136
|
+
allow_redirects: Whether to follow redirects
|
|
137
|
+
max_redirects: Maximum number of redirects to follow
|
|
138
|
+
cert: Client certificate (file or tuple of (cert, key))
|
|
139
|
+
trust_env: Whether to read proxy settings from environment
|
|
140
|
+
"""
|
|
141
|
+
# Resolve browser profile
|
|
142
|
+
self.profile: Optional[BrowserProfile] = None
|
|
143
|
+
if profile:
|
|
144
|
+
self.profile = profile
|
|
145
|
+
elif impersonate:
|
|
146
|
+
self.profile = get_profile(impersonate)
|
|
147
|
+
|
|
148
|
+
# Cookie jar — the heart of bas
|
|
149
|
+
self._cookie_jar = CookieJar()
|
|
150
|
+
|
|
151
|
+
# Pre-set cookies
|
|
152
|
+
if cookies:
|
|
153
|
+
domain = "" # unknown until first request
|
|
154
|
+
for name, value in cookies.items():
|
|
155
|
+
self._cookie_jar.add(Cookie(
|
|
156
|
+
name=str(name),
|
|
157
|
+
value=str(value),
|
|
158
|
+
domain=domain,
|
|
159
|
+
path="/",
|
|
160
|
+
))
|
|
161
|
+
|
|
162
|
+
# Default headers
|
|
163
|
+
self._headers = Headers()
|
|
164
|
+
if self.profile and self.profile.http_headers:
|
|
165
|
+
self._headers.update(self.profile.http_headers)
|
|
166
|
+
if headers:
|
|
167
|
+
self._headers.update(headers)
|
|
168
|
+
|
|
169
|
+
# Settings
|
|
170
|
+
self.verify = verify
|
|
171
|
+
self.timeout = timeout
|
|
172
|
+
self.allow_redirects = allow_redirects
|
|
173
|
+
self.max_redirects = max_redirects
|
|
174
|
+
self.cert = cert
|
|
175
|
+
self.proxies = proxies or {}
|
|
176
|
+
self.trust_env = trust_env
|
|
177
|
+
|
|
178
|
+
# Curl handle (lazy init)
|
|
179
|
+
self._curl: Optional[CurlEasy] = None
|
|
180
|
+
self._lock = threading.Lock()
|
|
181
|
+
|
|
182
|
+
# Stats
|
|
183
|
+
self._request_count = 0
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def cookies(self) -> CookieJar:
|
|
187
|
+
"""Direct access to the cookie jar."""
|
|
188
|
+
return self._cookie_jar
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def headers(self) -> Headers:
|
|
192
|
+
"""Default headers for all requests."""
|
|
193
|
+
return self._headers
|
|
194
|
+
|
|
195
|
+
@headers.setter
|
|
196
|
+
def headers(self, value: dict):
|
|
197
|
+
self._headers = Headers(value)
|
|
198
|
+
|
|
199
|
+
def _get_curl(self) -> "CurlEasy":
|
|
200
|
+
"""Get or create the curl handle."""
|
|
201
|
+
if self._curl is None:
|
|
202
|
+
if not HAS_CURL:
|
|
203
|
+
raise ConnectionError(
|
|
204
|
+
"libcurl is not available. Install libcurl-dev or set "
|
|
205
|
+
"FREEBUFF_CURL_PATH to the curl-impersonate library."
|
|
206
|
+
)
|
|
207
|
+
self._curl = CurlEasy()
|
|
208
|
+
if self.profile and self.profile.curl_impersonate:
|
|
209
|
+
# Set impersonate option (curl-impersonate specific)
|
|
210
|
+
# This is handled via COOKIELIST trick or env var
|
|
211
|
+
pass
|
|
212
|
+
return self._curl
|
|
213
|
+
|
|
214
|
+
def _build_curl_headers(self, request: PreparedRequest) -> "CurlSList":
|
|
215
|
+
"""Build a curl_slist* from request headers."""
|
|
216
|
+
slist = CurlSList()
|
|
217
|
+
for key, value in request.headers.items():
|
|
218
|
+
if value is not None:
|
|
219
|
+
slist.append(f"{key}: {value}")
|
|
220
|
+
return slist
|
|
221
|
+
|
|
222
|
+
def _curl_perform(self, request: PreparedRequest) -> Response:
|
|
223
|
+
"""
|
|
224
|
+
Execute a request using libcurl.
|
|
225
|
+
|
|
226
|
+
This is where the magic happens — we use our own CookieJar
|
|
227
|
+
instead of curl's broken cookie engine.
|
|
228
|
+
"""
|
|
229
|
+
curl = self._get_curl()
|
|
230
|
+
|
|
231
|
+
# Reset the handle for reuse
|
|
232
|
+
curl.reset()
|
|
233
|
+
|
|
234
|
+
# Prepare body
|
|
235
|
+
body = request.prepare_body()
|
|
236
|
+
|
|
237
|
+
# Build headers — include cookies from our jar
|
|
238
|
+
cookie_header = self._cookie_jar.to_header(request.url)
|
|
239
|
+
if cookie_header:
|
|
240
|
+
request.headers["Cookie"] = cookie_header
|
|
241
|
+
|
|
242
|
+
# Set URL
|
|
243
|
+
curl.setopt(CURLOPT_URL, request.url)
|
|
244
|
+
|
|
245
|
+
# Set method
|
|
246
|
+
if request.method == "GET":
|
|
247
|
+
curl.setopt(CURLOPT_HTTPGET, 1)
|
|
248
|
+
elif request.method == "POST":
|
|
249
|
+
curl.setopt(CURLOPT_POST, 1)
|
|
250
|
+
if body:
|
|
251
|
+
curl.setopt(CURLOPT_POSTFIELDS, body)
|
|
252
|
+
elif request.method in ("PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"):
|
|
253
|
+
curl.setopt(CURLOPT_CUSTOMREQUEST, request.method)
|
|
254
|
+
if body and request.method != "HEAD":
|
|
255
|
+
curl.setopt(CURLOPT_POSTFIELDS, body)
|
|
256
|
+
else:
|
|
257
|
+
curl.setopt(CURLOPT_CUSTOMREQUEST, request.method)
|
|
258
|
+
|
|
259
|
+
# Set headers (without Cookie — we handle that via CURLOPT_COOKIELIST)
|
|
260
|
+
# Actually, we'll set Cookie via the header list since we manage the jar
|
|
261
|
+
curl.set_headers(request.headers)
|
|
262
|
+
|
|
263
|
+
# SSL verification
|
|
264
|
+
curl.setopt(CURLOPT_SSL_VERIFYPEER, 1 if self.verify else 0)
|
|
265
|
+
curl.setopt(CURLOPT_SSL_VERIFYHOST, 2 if self.verify else 0)
|
|
266
|
+
|
|
267
|
+
# Timeouts
|
|
268
|
+
if self.timeout:
|
|
269
|
+
curl.setopt(CURLOPT_TIMEOUT, int(self.timeout))
|
|
270
|
+
if self.timeout:
|
|
271
|
+
curl.setopt(CURLOPT_CONNECTTIMEOUT, int(min(self.timeout, 10)))
|
|
272
|
+
|
|
273
|
+
# Proxy
|
|
274
|
+
proxy = self._get_proxy(request.url)
|
|
275
|
+
if proxy:
|
|
276
|
+
curl.setopt(CURLOPT_PROXY, proxy["server"])
|
|
277
|
+
if "port" in proxy:
|
|
278
|
+
curl.setopt(CURLOPT_PROXYPORT, proxy["port"])
|
|
279
|
+
if "username" in proxy:
|
|
280
|
+
# curl expects user:password format in proxy
|
|
281
|
+
auth = proxy.get("username", "")
|
|
282
|
+
if proxy.get("password"):
|
|
283
|
+
auth += ":" + proxy["password"]
|
|
284
|
+
curl.setopt(CURLOPT_PROXY, f"{proxy['server']}:{proxy['port']}")
|
|
285
|
+
proxy_type = proxy.get("type", "http").upper()
|
|
286
|
+
type_map = {
|
|
287
|
+
"HTTP": CURLPROXY_HTTP,
|
|
288
|
+
"SOCKS4": CURLPROXY_SOCKS4,
|
|
289
|
+
"SOCKS5": CURLPROXY_SOCKS5,
|
|
290
|
+
"SOCKS4A": CURLPROXY_SOCKS4A,
|
|
291
|
+
"SOCKS5_HOSTNAME": CURLPROXY_SOCKS5_HOSTNAME,
|
|
292
|
+
}
|
|
293
|
+
if proxy_type in type_map:
|
|
294
|
+
curl.setopt(CURLOPT_PROXYTYPE, type_map[proxy_type])
|
|
295
|
+
|
|
296
|
+
# Response body buffer
|
|
297
|
+
body_buffer = bytearray()
|
|
298
|
+
header_buffer = bytearray()
|
|
299
|
+
|
|
300
|
+
# Write callback
|
|
301
|
+
def write_func(buffer, size, nmemb, userp):
|
|
302
|
+
body_buffer.extend(buffer[:size * nmemb])
|
|
303
|
+
return size * nmemb
|
|
304
|
+
|
|
305
|
+
def header_func(buffer, size, nmemb, userp):
|
|
306
|
+
header_buffer.extend(buffer[:size * nmemb])
|
|
307
|
+
return size * nmemb
|
|
308
|
+
|
|
309
|
+
# Keep references to prevent GC
|
|
310
|
+
curl._write_cb = WRITEFUNC(write_func)
|
|
311
|
+
curl._header_cb = HEADERFUNC(header_func)
|
|
312
|
+
|
|
313
|
+
curl.setopt(CURLOPT_WRITEFUNCTION, curl._write_cb)
|
|
314
|
+
curl.setopt(CURLOPT_WRITEDATA, None)
|
|
315
|
+
curl.setopt(CURLOPT_HEADERFUNCTION, curl._header_cb)
|
|
316
|
+
curl.setopt(CURLOPT_HEADERDATA, None)
|
|
317
|
+
|
|
318
|
+
# Perform
|
|
319
|
+
start_time = time.time()
|
|
320
|
+
result = curl.perform()
|
|
321
|
+
elapsed = time.time() - start_time
|
|
322
|
+
|
|
323
|
+
if result != CURL_OK:
|
|
324
|
+
error_msg = curl.strerror(result)
|
|
325
|
+
if result == 28: # CURLE_OPERATION_TIMEDOUT
|
|
326
|
+
raise Timeout(f"Request timed out after {self.timeout}s: {request.url}")
|
|
327
|
+
raise ConnectionError(f"curl error {result}: {error_msg}")
|
|
328
|
+
|
|
329
|
+
# Get response info
|
|
330
|
+
status_code = curl.getinfo(CURLINFO_RESPONSE_CODE)
|
|
331
|
+
effective_url = curl.getinfo(CURLINFO_EFFECTIVE_URL) or request.url
|
|
332
|
+
content_type = curl.getinfo(CURLINFO_CONTENT_TYPE)
|
|
333
|
+
http_version_int = curl.getinfo(CURLINFO_HTTP_VERSION)
|
|
334
|
+
|
|
335
|
+
version_map = {
|
|
336
|
+
1: "1.0", 2: "1.1", 3: "2.0",
|
|
337
|
+
2 << 16: "2.0", 3 << 16: "3.0",
|
|
338
|
+
}
|
|
339
|
+
http_version = version_map.get(http_version_int, "1.1")
|
|
340
|
+
|
|
341
|
+
ip = curl.getinfo(CURLINFO_PRIMARY_IP) or ""
|
|
342
|
+
port = curl.getinfo(CURLINFO_PRIMARY_PORT) or 0
|
|
343
|
+
|
|
344
|
+
# Parse response headers
|
|
345
|
+
response_headers = self._parse_raw_headers(bytes(header_buffer))
|
|
346
|
+
|
|
347
|
+
# Build Response object
|
|
348
|
+
response = Response(
|
|
349
|
+
status_code=status_code,
|
|
350
|
+
headers=response_headers,
|
|
351
|
+
body=bytes(body_buffer),
|
|
352
|
+
url=effective_url,
|
|
353
|
+
request=request,
|
|
354
|
+
elapsed=elapsed,
|
|
355
|
+
http_version=http_version,
|
|
356
|
+
ip=ip or "",
|
|
357
|
+
port=port or 0,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# ── CRITICAL: Parse Set-Cookie and update our jar ──
|
|
361
|
+
# This is what curlcffi gets wrong — it relies on curl's internal
|
|
362
|
+
# cookie engine which loses cookies during redirects.
|
|
363
|
+
self._cookie_jar.parse_set_cookie_headers(
|
|
364
|
+
response_headers,
|
|
365
|
+
effective_url,
|
|
366
|
+
)
|
|
367
|
+
response.cookies = self._cookie_jar
|
|
368
|
+
|
|
369
|
+
self._request_count += 1
|
|
370
|
+
return response
|
|
371
|
+
|
|
372
|
+
def _parse_raw_headers(self, raw: bytes) -> Headers:
|
|
373
|
+
"""Parse raw HTTP headers from curl into a Headers object."""
|
|
374
|
+
headers = Headers()
|
|
375
|
+
try:
|
|
376
|
+
text = raw.decode("utf-8", errors="replace")
|
|
377
|
+
except Exception:
|
|
378
|
+
return headers
|
|
379
|
+
|
|
380
|
+
for line in text.split("\r\n"):
|
|
381
|
+
if not line or line.startswith("HTTP/"):
|
|
382
|
+
continue
|
|
383
|
+
if ":" in line:
|
|
384
|
+
key, _, value = line.partition(":")
|
|
385
|
+
key = key.strip()
|
|
386
|
+
value = value.strip()
|
|
387
|
+
if key:
|
|
388
|
+
# Handle duplicate headers (e.g., Set-Cookie)
|
|
389
|
+
existing = headers.get(key)
|
|
390
|
+
if existing is not None and key.lower() == "set-cookie":
|
|
391
|
+
# Convert to list for Set-Cookie headers
|
|
392
|
+
if isinstance(existing, list):
|
|
393
|
+
existing.append(value)
|
|
394
|
+
else:
|
|
395
|
+
headers[key] = [existing, value]
|
|
396
|
+
else:
|
|
397
|
+
headers[key] = value
|
|
398
|
+
return headers
|
|
399
|
+
|
|
400
|
+
def _get_proxy(self, url: str) -> Optional[dict]:
|
|
401
|
+
"""Get the proxy for a given URL."""
|
|
402
|
+
if not self.proxies:
|
|
403
|
+
return None
|
|
404
|
+
|
|
405
|
+
scheme = urlparse(url).scheme
|
|
406
|
+
if scheme == "https":
|
|
407
|
+
return self.proxies.get("https") or self.proxies.get("http")
|
|
408
|
+
return self.proxies.get("http")
|
|
409
|
+
|
|
410
|
+
def _prepare_request(
|
|
411
|
+
self,
|
|
412
|
+
method: str,
|
|
413
|
+
url: str,
|
|
414
|
+
headers: Optional[dict] = None,
|
|
415
|
+
data: Optional[dict] = None,
|
|
416
|
+
json: Optional[Any] = None,
|
|
417
|
+
params: Optional[dict] = None,
|
|
418
|
+
cookies: Optional[dict] = None,
|
|
419
|
+
files: Optional[dict] = None,
|
|
420
|
+
auth: Optional[tuple] = None,
|
|
421
|
+
) -> PreparedRequest:
|
|
422
|
+
"""Prepare a request with all default settings applied."""
|
|
423
|
+
# Resolve URL
|
|
424
|
+
if not url.startswith(("http://", "https://")):
|
|
425
|
+
raise ValueError(f"Invalid URL: {url!r}. Must start with http:// or https://")
|
|
426
|
+
|
|
427
|
+
# Merge headers
|
|
428
|
+
merged_headers = Headers(dict(self._headers))
|
|
429
|
+
if headers:
|
|
430
|
+
merged_headers.update(headers)
|
|
431
|
+
|
|
432
|
+
# Build request
|
|
433
|
+
request = PreparedRequest(
|
|
434
|
+
method=method,
|
|
435
|
+
url=url,
|
|
436
|
+
headers=merged_headers,
|
|
437
|
+
body=None,
|
|
438
|
+
json_data=json,
|
|
439
|
+
params=params,
|
|
440
|
+
auth=auth,
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
# Handle data
|
|
444
|
+
if data:
|
|
445
|
+
if isinstance(data, (dict, list)):
|
|
446
|
+
request.body = urlencode(data, doseq=True).encode("utf-8")
|
|
447
|
+
elif isinstance(data, str):
|
|
448
|
+
request.body = data.encode("utf-8")
|
|
449
|
+
elif isinstance(data, bytes):
|
|
450
|
+
request.body = data
|
|
451
|
+
# Add content type if not set
|
|
452
|
+
if "content-type" not in merged_headers:
|
|
453
|
+
merged_headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
454
|
+
|
|
455
|
+
# Handle query params
|
|
456
|
+
if params:
|
|
457
|
+
separator = "&" if "?" in url else "?"
|
|
458
|
+
url_with_params = url + separator + urlencode(params, doseq=True)
|
|
459
|
+
request.url = url_with_params
|
|
460
|
+
|
|
461
|
+
# Add per-request cookies
|
|
462
|
+
if cookies:
|
|
463
|
+
for name, value in cookies.items():
|
|
464
|
+
self._cookie_jar.add(Cookie(
|
|
465
|
+
name=str(name),
|
|
466
|
+
value=str(value),
|
|
467
|
+
domain=urlparse(url).hostname or "",
|
|
468
|
+
path="/",
|
|
469
|
+
))
|
|
470
|
+
|
|
471
|
+
return request
|
|
472
|
+
|
|
473
|
+
def _send_with_redirects(
|
|
474
|
+
self,
|
|
475
|
+
request: PreparedRequest,
|
|
476
|
+
allow_redirects: bool = True,
|
|
477
|
+
history: Optional[list] = None,
|
|
478
|
+
) -> Response:
|
|
479
|
+
"""
|
|
480
|
+
Execute a request, following redirects automatically.
|
|
481
|
+
|
|
482
|
+
THIS IS THE KEY FIX: During redirects, we:
|
|
483
|
+
1. Parse Set-Cookie from the redirect response
|
|
484
|
+
2. Merge those cookies into our jar
|
|
485
|
+
3. Re-apply cookies for the next request via our jar
|
|
486
|
+
4. Never lose a single cookie
|
|
487
|
+
|
|
488
|
+
This fixes curlcffi issue #55 where cookies were lost during redirects.
|
|
489
|
+
"""
|
|
490
|
+
if history is None:
|
|
491
|
+
history = []
|
|
492
|
+
|
|
493
|
+
response = self._curl_perform(request)
|
|
494
|
+
|
|
495
|
+
# Follow redirects
|
|
496
|
+
redirect_count = 0
|
|
497
|
+
while (
|
|
498
|
+
allow_redirects
|
|
499
|
+
and response.is_redirect
|
|
500
|
+
and redirect_count < self.max_redirects
|
|
501
|
+
):
|
|
502
|
+
# Get redirect URL
|
|
503
|
+
location = response.location
|
|
504
|
+
if not location:
|
|
505
|
+
break
|
|
506
|
+
|
|
507
|
+
# Resolve relative redirect
|
|
508
|
+
redirect_url = urljoin(response.url, location)
|
|
509
|
+
|
|
510
|
+
# ── COOKIE FIX: Parse Set-Cookie before redirect ──
|
|
511
|
+
# curlcffi loses cookies here because it switches to curl's
|
|
512
|
+
# internal cookie engine for the redirect. We don't.
|
|
513
|
+
# (Already done in _curl_perform above)
|
|
514
|
+
|
|
515
|
+
history.append(response)
|
|
516
|
+
|
|
517
|
+
# Build redirect request
|
|
518
|
+
redirect_method = request.method
|
|
519
|
+
if response.status_code in (301, 302, 303) or (
|
|
520
|
+
response.status_code == 307 and request.method == "POST"
|
|
521
|
+
):
|
|
522
|
+
# 303 always changes to GET
|
|
523
|
+
# 301/302 change POST to GET (by convention)
|
|
524
|
+
redirect_method = "GET"
|
|
525
|
+
|
|
526
|
+
redirect_request = PreparedRequest(
|
|
527
|
+
method=redirect_method,
|
|
528
|
+
url=redirect_url,
|
|
529
|
+
headers=Headers(dict(request.headers)),
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
# Remove sensitive headers for cross-origin redirects
|
|
533
|
+
if urlparse(redirect_url).hostname != urlparse(response.url).hostname:
|
|
534
|
+
# Don't send cookies/auth to different domains... actually
|
|
535
|
+
# we SHOULD because the server might have set cookies for
|
|
536
|
+
# the new domain. That's what real browsers do.
|
|
537
|
+
redirect_request.headers.pop("Authorization", None)
|
|
538
|
+
redirect_request.headers.pop("Cookie", None)
|
|
539
|
+
|
|
540
|
+
response = self._curl_perform(redirect_request)
|
|
541
|
+
redirect_count += 1
|
|
542
|
+
|
|
543
|
+
response.history = history
|
|
544
|
+
return response
|
|
545
|
+
|
|
546
|
+
# ── Public API ───────────────────────────────────────────────────────────
|
|
547
|
+
|
|
548
|
+
def request(
|
|
549
|
+
self,
|
|
550
|
+
method: str,
|
|
551
|
+
url: str,
|
|
552
|
+
**kwargs,
|
|
553
|
+
) -> Response:
|
|
554
|
+
"""
|
|
555
|
+
Send a prepared request.
|
|
556
|
+
|
|
557
|
+
This is the core method that all HTTP methods delegate to.
|
|
558
|
+
"""
|
|
559
|
+
allow_redirects = kwargs.pop("allow_redirects", self.allow_redirects)
|
|
560
|
+
request = self._prepare_request(method, url, **kwargs)
|
|
561
|
+
return self._send_with_redirects(request, allow_redirects=allow_redirects)
|
|
562
|
+
|
|
563
|
+
def get(self, url: str, **kwargs) -> Response:
|
|
564
|
+
"""Send a GET request."""
|
|
565
|
+
return self.request("GET", url, **kwargs)
|
|
566
|
+
|
|
567
|
+
def post(self, url: str, **kwargs) -> Response:
|
|
568
|
+
"""Send a POST request."""
|
|
569
|
+
return self.request("POST", url, **kwargs)
|
|
570
|
+
|
|
571
|
+
def put(self, url: str, **kwargs) -> Response:
|
|
572
|
+
"""Send a PUT request."""
|
|
573
|
+
return self.request("PUT", url, **kwargs)
|
|
574
|
+
|
|
575
|
+
def delete(self, url: str, **kwargs) -> Response:
|
|
576
|
+
"""Send a DELETE request."""
|
|
577
|
+
return self.request("DELETE", url, **kwargs)
|
|
578
|
+
|
|
579
|
+
def patch(self, url: str, **kwargs) -> Response:
|
|
580
|
+
"""Send a PATCH request."""
|
|
581
|
+
return self.request("PATCH", url, **kwargs)
|
|
582
|
+
|
|
583
|
+
def head(self, url: str, **kwargs) -> Response:
|
|
584
|
+
"""Send a HEAD request."""
|
|
585
|
+
return self.request("HEAD", url, **kwargs)
|
|
586
|
+
|
|
587
|
+
def options(self, url: str, **kwargs) -> Response:
|
|
588
|
+
"""Send an OPTIONS request."""
|
|
589
|
+
return self.request("OPTIONS", url, **kwargs)
|
|
590
|
+
|
|
591
|
+
def close(self) -> None:
|
|
592
|
+
"""Close the session and release resources."""
|
|
593
|
+
if self._curl:
|
|
594
|
+
self._curl = None
|
|
595
|
+
|
|
596
|
+
def __enter__(self):
|
|
597
|
+
return self
|
|
598
|
+
|
|
599
|
+
def __exit__(self, *args):
|
|
600
|
+
self.close()
|
|
601
|
+
|
|
602
|
+
def __repr__(self):
|
|
603
|
+
impersonate = self.profile.name if self.profile else None
|
|
604
|
+
return f"<Session(impersonate={impersonate!r}, requests={self._request_count})>"
|
|
605
|
+
|
|
606
|
+
# ── Cookie convenience methods ───────────────────────────────────────────
|
|
607
|
+
|
|
608
|
+
def get_cookies(self, url: str) -> dict:
|
|
609
|
+
"""Get cookies for a URL as a {name: value} dict."""
|
|
610
|
+
return self._cookie_jar.get_dict(url)
|
|
611
|
+
|
|
612
|
+
def set_cookie(self, name: str, value: str, domain: str = "", path: str = "/") -> None:
|
|
613
|
+
"""Manually set a cookie."""
|
|
614
|
+
self._cookie_jar.add(Cookie(name=name, value=value, domain=domain, path=path))
|
|
615
|
+
|
|
616
|
+
def clear_cookies(self) -> None:
|
|
617
|
+
"""Remove all cookies."""
|
|
618
|
+
self._cookie_jar.clear()
|
|
619
|
+
|
|
620
|
+
def save_cookies(self, filepath: str, format: str = "json") -> None:
|
|
621
|
+
"""
|
|
622
|
+
Save cookies to a file.
|
|
623
|
+
|
|
624
|
+
Args:
|
|
625
|
+
filepath: Path to save file
|
|
626
|
+
format: 'json' or 'netscape'
|
|
627
|
+
"""
|
|
628
|
+
if format == "netscape":
|
|
629
|
+
self._cookie_jar.save_netscape(filepath)
|
|
630
|
+
else:
|
|
631
|
+
self._cookie_jar.save_json(filepath)
|
|
632
|
+
|
|
633
|
+
def load_cookies(self, filepath: str, format: str = "json") -> None:
|
|
634
|
+
"""
|
|
635
|
+
Load cookies from a file.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
filepath: Path to cookie file
|
|
639
|
+
format: 'json' or 'netscape'
|
|
640
|
+
"""
|
|
641
|
+
if format == "netscape":
|
|
642
|
+
self._cookie_jar.load_netscape(filepath)
|
|
643
|
+
else:
|
|
644
|
+
self._cookie_jar.load_json(filepath)
|
|
645
|
+
|
|
646
|
+
def merge_cookies(self, other_jar: CookieJar) -> None:
|
|
647
|
+
"""Merge another cookie jar into this session's jar."""
|
|
648
|
+
self._cookie_jar.update(other_jar)
|