dataapp_crawler 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.
- dataapp_crawler/__init__.py +87 -0
- dataapp_crawler/__main__.py +35 -0
- dataapp_crawler/_logging.py +148 -0
- dataapp_crawler/_network_diagnostics.py +193 -0
- dataapp_crawler/browser_tools/__init__.py +23 -0
- dataapp_crawler/browser_tools/_common.py +66 -0
- dataapp_crawler/browser_tools/dom.py +49 -0
- dataapp_crawler/browser_tools/lists.py +125 -0
- dataapp_crawler/browser_tools/navigation.py +243 -0
- dataapp_crawler/browser_tools/readiness.py +42 -0
- dataapp_crawler/captcha.py +912 -0
- dataapp_crawler/checkpoint.py +487 -0
- dataapp_crawler/cloudflare.py +523 -0
- dataapp_crawler/context.py +231 -0
- dataapp_crawler/cookies.py +207 -0
- dataapp_crawler/crawler.py +1392 -0
- dataapp_crawler/errors.py +184 -0
- dataapp_crawler/http.py +223 -0
- dataapp_crawler/images.py +267 -0
- dataapp_crawler/local_auth.py +77 -0
- dataapp_crawler/models.py +285 -0
- dataapp_crawler/pagination.py +172 -0
- dataapp_crawler/parsing.py +13 -0
- dataapp_crawler/providers/__init__.py +20 -0
- dataapp_crawler/providers/browser.py +292 -0
- dataapp_crawler/providers/camoufox.py +184 -0
- dataapp_crawler/providers/dataos.py +174 -0
- dataapp_crawler/providers/pool.py +451 -0
- dataapp_crawler/providers/proxy.py +209 -0
- dataapp_crawler/py.typed +0 -0
- dataapp_crawler/routing.py +67 -0
- dataapp_crawler/scheduling.py +425 -0
- dataapp_crawler/sessions.py +735 -0
- dataapp_crawler-0.1.0.dist-info/METADATA +211 -0
- dataapp_crawler-0.1.0.dist-info/RECORD +36 -0
- dataapp_crawler-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Cooperative cancellation and bounded execution, independent of platform internals."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import math
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
10
|
+
from contextvars import ContextVar, Token
|
|
11
|
+
from fnmatch import fnmatchcase
|
|
12
|
+
from itertools import islice
|
|
13
|
+
from typing import Any, Literal, TypeVar
|
|
14
|
+
from urllib.parse import urldefrag, urljoin
|
|
15
|
+
|
|
16
|
+
from .errors import ConfigurationError, CrawlCancelled, LimitReached
|
|
17
|
+
from .models import Limits, Request, Stats, origin
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
_CURRENT_SESSION: ContextVar[tuple[Any, Any] | None] = ContextVar(
|
|
22
|
+
"dataapp_crawler_current_session", default=None
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CrawlContext:
|
|
27
|
+
def __init__(
|
|
28
|
+
self, limits: Limits, *, run_id: str | None = None, attempt_id: str | None = None
|
|
29
|
+
) -> None:
|
|
30
|
+
self.limits = limits
|
|
31
|
+
self.run_id = run_id
|
|
32
|
+
self.attempt_id = attempt_id
|
|
33
|
+
self.stats = Stats()
|
|
34
|
+
self.cancelled = asyncio.Event()
|
|
35
|
+
self._started: float | None = None
|
|
36
|
+
self._enqueue: Callable[[Request], bool] | None = None
|
|
37
|
+
self._seen: set[str] = set()
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def session(self) -> Any | None:
|
|
41
|
+
"""Safe metadata and health controls for the current exclusive pool lease."""
|
|
42
|
+
current = _CURRENT_SESSION.get()
|
|
43
|
+
if current is None or current[0] is not self:
|
|
44
|
+
return None
|
|
45
|
+
session = current[1]
|
|
46
|
+
return session if session.active else None
|
|
47
|
+
|
|
48
|
+
def _set_session(self, session: Any) -> Token[tuple[Any, Any] | None]:
|
|
49
|
+
return _CURRENT_SESSION.set((self, session))
|
|
50
|
+
|
|
51
|
+
def _reset_session(self, token: Token[tuple[Any, Any] | None]) -> None:
|
|
52
|
+
_CURRENT_SESSION.reset(token)
|
|
53
|
+
|
|
54
|
+
def start(self) -> None:
|
|
55
|
+
if self._started is None:
|
|
56
|
+
self._started = time.monotonic()
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def remaining(self) -> float:
|
|
60
|
+
self.start()
|
|
61
|
+
assert self._started is not None
|
|
62
|
+
return max(0, self.limits.max_duration - (time.monotonic() - self._started))
|
|
63
|
+
|
|
64
|
+
def check(self) -> None:
|
|
65
|
+
if self.cancelled.is_set():
|
|
66
|
+
raise CrawlCancelled("Acquisition cancelled")
|
|
67
|
+
if self.remaining <= 0:
|
|
68
|
+
self.stats.stop_reason = "max_duration"
|
|
69
|
+
raise LimitReached("Acquisition duration limit reached")
|
|
70
|
+
|
|
71
|
+
def cancel(self, reason: str = "cancelled") -> None:
|
|
72
|
+
self.stats.stop_reason = reason
|
|
73
|
+
self.cancelled.set()
|
|
74
|
+
|
|
75
|
+
def stop(self) -> None:
|
|
76
|
+
self.cancel("developer_stop")
|
|
77
|
+
|
|
78
|
+
def enqueue(self, request: Request | str) -> bool:
|
|
79
|
+
"""Add follow-up work; false means duplicate. Full queues raise QueueFull."""
|
|
80
|
+
self.check()
|
|
81
|
+
if self._enqueue is None:
|
|
82
|
+
raise RuntimeError("enqueue is only available during crawl")
|
|
83
|
+
return self._enqueue(Request(request) if isinstance(request, str) else request)
|
|
84
|
+
|
|
85
|
+
def enqueue_links(
|
|
86
|
+
self,
|
|
87
|
+
links: Iterable[str],
|
|
88
|
+
*,
|
|
89
|
+
base_url: str,
|
|
90
|
+
label: str = "",
|
|
91
|
+
mode: Literal["http", "browser"] = "http",
|
|
92
|
+
same_origin: bool = True,
|
|
93
|
+
include: Iterable[str] = (),
|
|
94
|
+
exclude: Iterable[str] = (),
|
|
95
|
+
max_links: int = 100,
|
|
96
|
+
) -> int:
|
|
97
|
+
"""Resolve and enqueue bounded links from a developer's DOM/JSON/Page parser.
|
|
98
|
+
|
|
99
|
+
Patterns use case-sensitive URL globs. Inspect at most ``max_links``
|
|
100
|
+
candidates, strip fragments, and use the crawl queue's existing deduplication
|
|
101
|
+
and limits. Cross-origin links require an explicit ``same_origin=False``.
|
|
102
|
+
"""
|
|
103
|
+
self.check()
|
|
104
|
+
if self._enqueue is None:
|
|
105
|
+
raise RuntimeError("enqueue_links is only available during crawl")
|
|
106
|
+
if isinstance(max_links, bool) or not isinstance(max_links, int) or max_links < 1:
|
|
107
|
+
raise ConfigurationError("max_links must be a positive integer")
|
|
108
|
+
if mode not in {"http", "browser"} or not isinstance(same_origin, bool):
|
|
109
|
+
raise ConfigurationError("Invalid link acquisition mode or origin policy")
|
|
110
|
+
source_origin = origin(base_url)
|
|
111
|
+
included, excluded = tuple(include), tuple(exclude)
|
|
112
|
+
added = 0
|
|
113
|
+
for href in islice(links, max_links):
|
|
114
|
+
self.check()
|
|
115
|
+
if not isinstance(href, str) or not href.strip() or href.lstrip().startswith("#"):
|
|
116
|
+
continue
|
|
117
|
+
try:
|
|
118
|
+
url = urldefrag(urljoin(base_url, href.strip()))[0]
|
|
119
|
+
target_origin = origin(url)
|
|
120
|
+
except (ConfigurationError, ValueError):
|
|
121
|
+
continue
|
|
122
|
+
if same_origin and target_origin != source_origin:
|
|
123
|
+
continue
|
|
124
|
+
if included and not any(fnmatchcase(url, pattern) for pattern in included):
|
|
125
|
+
continue
|
|
126
|
+
if any(fnmatchcase(url, pattern) for pattern in excluded):
|
|
127
|
+
continue
|
|
128
|
+
added += self.enqueue(Request(url, label=label, mode=mode))
|
|
129
|
+
return added
|
|
130
|
+
|
|
131
|
+
def seen(self, key: str) -> bool:
|
|
132
|
+
"""Return whether a record/cursor key was already seen, then remember it."""
|
|
133
|
+
present = key in self._seen
|
|
134
|
+
if not present:
|
|
135
|
+
if len(self._seen) >= self.limits.max_results + self.limits.max_requests:
|
|
136
|
+
raise LimitReached("Seen-key memory limit reached")
|
|
137
|
+
self._seen.add(key)
|
|
138
|
+
return present
|
|
139
|
+
|
|
140
|
+
def reserve_request(self) -> None:
|
|
141
|
+
self.check()
|
|
142
|
+
if self.stats.requests >= self.limits.max_requests:
|
|
143
|
+
self.stats.stop_reason = "max_requests"
|
|
144
|
+
raise LimitReached("Request limit reached")
|
|
145
|
+
self.stats.requests += 1
|
|
146
|
+
|
|
147
|
+
async def run(self, awaitable: Awaitable[T], *, timeout: float | None = None) -> T:
|
|
148
|
+
"""Run one operation within cancellation and time budgets.
|
|
149
|
+
|
|
150
|
+
``timeout`` bounds this operation while the remaining ``max_duration``
|
|
151
|
+
budget still bounds the whole crawl. An operation timeout raises the
|
|
152
|
+
standard :class:`TimeoutError`; exhausting the crawl budget raises
|
|
153
|
+
:class:`LimitReached`. The owned task is cancelled and joined on every
|
|
154
|
+
timeout or cancellation path.
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
if timeout is not None and (
|
|
158
|
+
isinstance(timeout, bool)
|
|
159
|
+
or not isinstance(timeout, (int, float))
|
|
160
|
+
or not math.isfinite(timeout)
|
|
161
|
+
or timeout <= 0
|
|
162
|
+
):
|
|
163
|
+
raise ConfigurationError("timeout must be a finite positive number or None")
|
|
164
|
+
self.check()
|
|
165
|
+
except BaseException:
|
|
166
|
+
# Calling an async function creates a coroutine before this method can
|
|
167
|
+
# validate its keyword arguments. Close that unstarted coroutine so a
|
|
168
|
+
# configuration error never produces an unawaited-coroutine warning.
|
|
169
|
+
if inspect.iscoroutine(awaitable):
|
|
170
|
+
awaitable.close()
|
|
171
|
+
else:
|
|
172
|
+
rejected_task = asyncio.ensure_future(awaitable)
|
|
173
|
+
if not rejected_task.done():
|
|
174
|
+
rejected_task.cancel()
|
|
175
|
+
await asyncio.gather(rejected_task, return_exceptions=True)
|
|
176
|
+
raise
|
|
177
|
+
|
|
178
|
+
remaining = self.remaining
|
|
179
|
+
operation_timeout = float(timeout) if timeout is not None else None
|
|
180
|
+
deadline = min(remaining, operation_timeout) if operation_timeout is not None else remaining
|
|
181
|
+
operation_deadline_first = operation_timeout is not None and operation_timeout < remaining
|
|
182
|
+
task = asyncio.ensure_future(awaitable)
|
|
183
|
+
abort = asyncio.create_task(self.cancelled.wait())
|
|
184
|
+
try:
|
|
185
|
+
done, _ = await asyncio.wait(
|
|
186
|
+
{task, abort}, timeout=deadline, return_when=asyncio.FIRST_COMPLETED
|
|
187
|
+
)
|
|
188
|
+
if abort in done:
|
|
189
|
+
raise CrawlCancelled("Acquisition cancelled")
|
|
190
|
+
if task in done:
|
|
191
|
+
return task.result()
|
|
192
|
+
if operation_deadline_first:
|
|
193
|
+
raise TimeoutError("Operation timeout reached")
|
|
194
|
+
self.stats.stop_reason = "max_duration"
|
|
195
|
+
raise LimitReached("Acquisition duration limit reached")
|
|
196
|
+
finally:
|
|
197
|
+
for pending_task in (task, abort):
|
|
198
|
+
if not pending_task.done():
|
|
199
|
+
pending_task.cancel()
|
|
200
|
+
await asyncio.gather(task, abort, return_exceptions=True)
|
|
201
|
+
|
|
202
|
+
async def sleep(self, delay: float) -> None:
|
|
203
|
+
await self.run(asyncio.sleep(max(0, delay)))
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class SiteGate:
|
|
207
|
+
"""Start-rate and concurrency limits shared by all requests/retries to one site."""
|
|
208
|
+
|
|
209
|
+
def __init__(self, context: CrawlContext) -> None:
|
|
210
|
+
self.context = context
|
|
211
|
+
self.semaphore = asyncio.Semaphore(context.limits.per_site_concurrency)
|
|
212
|
+
self._lock = asyncio.Lock()
|
|
213
|
+
self._next_start = 0.0
|
|
214
|
+
|
|
215
|
+
def defer(self, delay: float) -> None:
|
|
216
|
+
self._next_start = max(self._next_start, time.monotonic() + delay)
|
|
217
|
+
|
|
218
|
+
async def __aenter__(self) -> SiteGate:
|
|
219
|
+
await self.context.run(self.semaphore.acquire())
|
|
220
|
+
try:
|
|
221
|
+
async with self._lock:
|
|
222
|
+
while self._next_start > time.monotonic():
|
|
223
|
+
await self.context.sleep(self._next_start - time.monotonic())
|
|
224
|
+
self._next_start = time.monotonic() + self.context.limits.min_interval
|
|
225
|
+
except BaseException:
|
|
226
|
+
self.semaphore.release()
|
|
227
|
+
raise
|
|
228
|
+
return self
|
|
229
|
+
|
|
230
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
231
|
+
self.semaphore.release()
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Cookie conversion and reconciliation for opt-in cross-engine sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.cookiejar
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .errors import ConfigurationError
|
|
12
|
+
|
|
13
|
+
CookieKey = tuple[str, str, str]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _same_site(value: Any) -> str | None:
|
|
17
|
+
if not isinstance(value, str):
|
|
18
|
+
return None
|
|
19
|
+
normalized = value.lower()
|
|
20
|
+
return {"strict": "Strict", "lax": "Lax", "none": "None"}.get(normalized)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _rest_value(cookie: http.cookiejar.Cookie, name: str) -> Any:
|
|
24
|
+
for key, value in getattr(cookie, "_rest", {}).items():
|
|
25
|
+
if key.lower() == name.lower():
|
|
26
|
+
return value
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _has_rest(cookie: http.cookiejar.Cookie, name: str) -> bool:
|
|
31
|
+
return any(key.lower() == name.lower() for key in getattr(cookie, "_rest", {}))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _as_bool(value: Any) -> bool:
|
|
35
|
+
if isinstance(value, str):
|
|
36
|
+
return value.lower() not in {"", "0", "false", "no"}
|
|
37
|
+
return bool(value)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_partitioned_record(cookie: dict[str, Any]) -> bool:
|
|
41
|
+
return "partitionKey" in cookie or cookie.get("partitioned") is not None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _normalized(cookie: dict[str, Any]) -> dict[str, Any] | None:
|
|
45
|
+
if _is_partitioned_record(cookie):
|
|
46
|
+
raise ConfigurationError("Partitioned cookies cannot be shared across engines")
|
|
47
|
+
name = cookie.get("name")
|
|
48
|
+
domain = cookie.get("domain")
|
|
49
|
+
if not isinstance(name, str) or not name or not isinstance(domain, str) or not domain:
|
|
50
|
+
return None
|
|
51
|
+
expires = cookie.get("expires")
|
|
52
|
+
if not isinstance(expires, (int, float)) or expires < 0:
|
|
53
|
+
expires = None
|
|
54
|
+
if expires is not None and expires <= time.time():
|
|
55
|
+
return None
|
|
56
|
+
host_only = cookie.get("hostOnly")
|
|
57
|
+
if not isinstance(host_only, bool):
|
|
58
|
+
host_only = not domain.startswith(".")
|
|
59
|
+
if host_only:
|
|
60
|
+
domain = domain.lstrip(".")
|
|
61
|
+
elif not domain.startswith("."):
|
|
62
|
+
domain = f".{domain}"
|
|
63
|
+
result: dict[str, Any] = {
|
|
64
|
+
"name": name,
|
|
65
|
+
"value": str(cookie.get("value", "")),
|
|
66
|
+
"domain": domain.lower(),
|
|
67
|
+
"path": cookie.get("path") or "/",
|
|
68
|
+
"hostOnly": host_only,
|
|
69
|
+
"secure": bool(cookie.get("secure", False)),
|
|
70
|
+
"httpOnly": bool(cookie.get("httpOnly", False)),
|
|
71
|
+
"expires": expires,
|
|
72
|
+
}
|
|
73
|
+
same_site = _same_site(cookie.get("sameSite"))
|
|
74
|
+
if same_site is not None:
|
|
75
|
+
result["sameSite"] = same_site
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cookies_from_jar(jar: http.cookiejar.CookieJar) -> list[dict[str, Any]]:
|
|
80
|
+
"""Export current cookies without exposing HTTP headers or other identity state."""
|
|
81
|
+
jar.clear_expired_cookies()
|
|
82
|
+
exported: list[dict[str, Any]] = []
|
|
83
|
+
for cookie in jar:
|
|
84
|
+
if _has_rest(cookie, "Partitioned") or _has_rest(cookie, "partitionKey"):
|
|
85
|
+
raise ConfigurationError("Partitioned cookies cannot be shared across engines")
|
|
86
|
+
normalized = _normalized(
|
|
87
|
+
{
|
|
88
|
+
"name": cookie.name,
|
|
89
|
+
"value": cookie.value,
|
|
90
|
+
"domain": cookie.domain,
|
|
91
|
+
"path": cookie.path,
|
|
92
|
+
"hostOnly": not cookie.domain_specified,
|
|
93
|
+
"secure": cookie.secure,
|
|
94
|
+
"httpOnly": _as_bool(_rest_value(cookie, "HttpOnly")),
|
|
95
|
+
"sameSite": _rest_value(cookie, "SameSite"),
|
|
96
|
+
"expires": cookie.expires,
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
if normalized is not None:
|
|
100
|
+
exported.append(normalized)
|
|
101
|
+
return exported
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def is_partitioned_jar_cookie(cookie: http.cookiejar.Cookie) -> bool:
|
|
105
|
+
return _has_rest(cookie, "Partitioned") or _has_rest(cookie, "partitionKey")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def validate_shareable_cookies(cookies: Iterable[dict[str, Any]]) -> None:
|
|
109
|
+
"""Fail before a bridge can silently downgrade partitioned browser state."""
|
|
110
|
+
for cookie in cookies:
|
|
111
|
+
_normalized(cookie)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def cookie_to_jar(cookie: dict[str, Any]) -> http.cookiejar.Cookie | None:
|
|
115
|
+
normalized = _normalized(cookie)
|
|
116
|
+
if normalized is None:
|
|
117
|
+
return None
|
|
118
|
+
rest: dict[str, Any] = {}
|
|
119
|
+
if normalized["httpOnly"]:
|
|
120
|
+
rest["HttpOnly"] = True
|
|
121
|
+
if "sameSite" in normalized:
|
|
122
|
+
rest["SameSite"] = normalized["sameSite"]
|
|
123
|
+
domain = normalized["domain"]
|
|
124
|
+
host_only = normalized["hostOnly"]
|
|
125
|
+
expires = normalized["expires"]
|
|
126
|
+
return http.cookiejar.Cookie(
|
|
127
|
+
version=0,
|
|
128
|
+
name=normalized["name"],
|
|
129
|
+
value=normalized["value"],
|
|
130
|
+
port=None,
|
|
131
|
+
port_specified=False,
|
|
132
|
+
domain=domain,
|
|
133
|
+
domain_specified=not host_only,
|
|
134
|
+
domain_initial_dot=domain.startswith("."),
|
|
135
|
+
path=normalized["path"],
|
|
136
|
+
path_specified=True,
|
|
137
|
+
secure=normalized["secure"],
|
|
138
|
+
expires=expires,
|
|
139
|
+
discard=expires is None,
|
|
140
|
+
comment=None,
|
|
141
|
+
comment_url=None,
|
|
142
|
+
rest=rest,
|
|
143
|
+
rfc2109=False,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def replace_jar(
|
|
148
|
+
jar: http.cookiejar.CookieJar,
|
|
149
|
+
cookies: Iterable[dict[str, Any]],
|
|
150
|
+
*,
|
|
151
|
+
ignore_partitioned: bool = False,
|
|
152
|
+
) -> None:
|
|
153
|
+
jar.clear()
|
|
154
|
+
for item in cookies:
|
|
155
|
+
if ignore_partitioned and _is_partitioned_record(item):
|
|
156
|
+
continue
|
|
157
|
+
cookie = cookie_to_jar(item)
|
|
158
|
+
if cookie is not None:
|
|
159
|
+
jar.set_cookie(cookie)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def cookies_for_browser(cookies: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
163
|
+
result: list[dict[str, Any]] = []
|
|
164
|
+
for item in cookies:
|
|
165
|
+
cookie = _normalized(item)
|
|
166
|
+
if cookie is None:
|
|
167
|
+
continue
|
|
168
|
+
browser_cookie: dict[str, Any] = {
|
|
169
|
+
"name": cookie["name"],
|
|
170
|
+
"value": cookie["value"],
|
|
171
|
+
"domain": cookie["domain"],
|
|
172
|
+
"path": cookie["path"],
|
|
173
|
+
"secure": cookie["secure"],
|
|
174
|
+
"httpOnly": cookie["httpOnly"],
|
|
175
|
+
"expires": cookie["expires"] if cookie["expires"] is not None else -1,
|
|
176
|
+
}
|
|
177
|
+
if "sameSite" in cookie:
|
|
178
|
+
browser_cookie["sameSite"] = cookie["sameSite"]
|
|
179
|
+
result.append(browser_cookie)
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass
|
|
184
|
+
class CookieState:
|
|
185
|
+
"""A session-local canonical snapshot; callers serialize access through the lease."""
|
|
186
|
+
|
|
187
|
+
_cookies: dict[CookieKey, dict[str, Any]] = field(default_factory=dict, repr=False)
|
|
188
|
+
|
|
189
|
+
@classmethod
|
|
190
|
+
def from_jar(cls, jar: http.cookiejar.CookieJar) -> CookieState:
|
|
191
|
+
state = cls()
|
|
192
|
+
state.replace(cookies_from_jar(jar))
|
|
193
|
+
return state
|
|
194
|
+
|
|
195
|
+
def replace(self, cookies: Iterable[dict[str, Any]]) -> None:
|
|
196
|
+
replacement: dict[CookieKey, dict[str, Any]] = {}
|
|
197
|
+
for item in cookies:
|
|
198
|
+
cookie = _normalized(item)
|
|
199
|
+
if cookie is None:
|
|
200
|
+
continue
|
|
201
|
+
key = (cookie["domain"], cookie["path"], cookie["name"])
|
|
202
|
+
replacement[key] = cookie
|
|
203
|
+
self._cookies = replacement
|
|
204
|
+
|
|
205
|
+
def snapshot(self) -> list[dict[str, Any]]:
|
|
206
|
+
self.replace(self._cookies.values())
|
|
207
|
+
return [dict(self._cookies[key]) for key in sorted(self._cookies)]
|