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,735 @@
|
|
|
1
|
+
"""Bounded, run-local anonymous acquisition sessions.
|
|
2
|
+
|
|
3
|
+
HTTP cookies and browser context state persist independently inside one slot by
|
|
4
|
+
default. An explicitly enabled cookie bridge can reconcile cookies only; browser
|
|
5
|
+
storage state and identity headers remain engine-local.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import inspect
|
|
12
|
+
import math
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine
|
|
16
|
+
from contextlib import asynccontextmanager
|
|
17
|
+
from copy import deepcopy
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any
|
|
20
|
+
from uuid import uuid4
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from .cookies import CookieState, cookies_for_browser, validate_shareable_cookies
|
|
25
|
+
from .errors import ConfigurationError, FetchError
|
|
26
|
+
from .http import HttpSession
|
|
27
|
+
from .models import Identity, Limits
|
|
28
|
+
|
|
29
|
+
CloseResource = Callable[[Any], Awaitable[None]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class SessionInfo:
|
|
34
|
+
"""Safe metadata passed to factories; ``health`` is a lower-is-better error score."""
|
|
35
|
+
|
|
36
|
+
id: str
|
|
37
|
+
health: int
|
|
38
|
+
use_count: int
|
|
39
|
+
age: float
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
Factory = Callable[[SessionInfo], Any]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Session:
|
|
46
|
+
"""The current exclusive session lease exposed through ``context.session``."""
|
|
47
|
+
|
|
48
|
+
__slots__ = ("_slot", "_active")
|
|
49
|
+
|
|
50
|
+
def __init__(self, slot: _Slot) -> None:
|
|
51
|
+
self._slot: _Slot | None = slot
|
|
52
|
+
self._active = True
|
|
53
|
+
|
|
54
|
+
def _check(self) -> None:
|
|
55
|
+
if not self._active or self._slot is None:
|
|
56
|
+
raise RuntimeError("Session lease is no longer active")
|
|
57
|
+
|
|
58
|
+
def _invalidate(self) -> None:
|
|
59
|
+
self._active = False
|
|
60
|
+
self._slot = None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def active(self) -> bool:
|
|
64
|
+
"""Whether this view still owns its exclusive lease."""
|
|
65
|
+
return self._active
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def id(self) -> str:
|
|
69
|
+
self._check()
|
|
70
|
+
assert self._slot is not None
|
|
71
|
+
return self._slot.id
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def health(self) -> int:
|
|
75
|
+
"""Current error score; zero is healthiest and ``mark_good`` lowers it."""
|
|
76
|
+
self._check()
|
|
77
|
+
assert self._slot is not None
|
|
78
|
+
return self._slot.error_score
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def use_count(self) -> int:
|
|
82
|
+
self._check()
|
|
83
|
+
assert self._slot is not None
|
|
84
|
+
return self._slot.use_count
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def age(self) -> float:
|
|
88
|
+
self._check()
|
|
89
|
+
assert self._slot is not None
|
|
90
|
+
return max(0.0, time.monotonic() - self._slot.created_at)
|
|
91
|
+
|
|
92
|
+
def mark_good(self) -> None:
|
|
93
|
+
self._check()
|
|
94
|
+
assert self._slot is not None
|
|
95
|
+
self._slot.error_score = max(0, self._slot.error_score - 1)
|
|
96
|
+
|
|
97
|
+
def mark_bad(self) -> None:
|
|
98
|
+
self._check()
|
|
99
|
+
assert self._slot is not None
|
|
100
|
+
self._slot.error_score += 1
|
|
101
|
+
|
|
102
|
+
def retire(self) -> None:
|
|
103
|
+
self._check()
|
|
104
|
+
assert self._slot is not None
|
|
105
|
+
self._slot.retire = True
|
|
106
|
+
|
|
107
|
+
def __repr__(self) -> str:
|
|
108
|
+
if not self._active:
|
|
109
|
+
return "Session(inactive)"
|
|
110
|
+
return (
|
|
111
|
+
f"Session(id={self.id!r}, health={self.health}, "
|
|
112
|
+
f"use_count={self.use_count}, age={self.age:.3f})"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class _Slot:
|
|
118
|
+
id: str
|
|
119
|
+
affinity: tuple[str, str]
|
|
120
|
+
origin: str
|
|
121
|
+
identity: Identity
|
|
122
|
+
proxy: Any
|
|
123
|
+
http: HttpSession
|
|
124
|
+
browser_provider: Any
|
|
125
|
+
created_at: float = field(default_factory=time.monotonic)
|
|
126
|
+
last_used: float = field(default_factory=time.monotonic)
|
|
127
|
+
use_count: int = 0
|
|
128
|
+
error_score: int = 0
|
|
129
|
+
leased: bool = False
|
|
130
|
+
retire: bool = False
|
|
131
|
+
browser: Any = None
|
|
132
|
+
browser_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
133
|
+
cookie_state: CookieState | None = None
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def info(self) -> SessionInfo:
|
|
137
|
+
return SessionInfo(
|
|
138
|
+
self.id,
|
|
139
|
+
self.error_score,
|
|
140
|
+
self.use_count,
|
|
141
|
+
max(0.0, time.monotonic() - self.created_at),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class SessionLease:
|
|
146
|
+
"""Internal resources for one exclusive pool lease."""
|
|
147
|
+
|
|
148
|
+
__slots__ = ("_slot", "session")
|
|
149
|
+
|
|
150
|
+
def __init__(self, slot: _Slot) -> None:
|
|
151
|
+
self._slot = slot
|
|
152
|
+
self.session = Session(slot)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def http(self) -> HttpSession:
|
|
156
|
+
return self._slot.http
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def identity(self) -> Identity:
|
|
160
|
+
return self._slot.identity
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def proxy(self) -> Any:
|
|
164
|
+
return self._slot.proxy
|
|
165
|
+
|
|
166
|
+
async def sync_to_http(self) -> None:
|
|
167
|
+
"""Apply canonical cookies before HTTP acquisition when sharing is enabled."""
|
|
168
|
+
state = self._slot.cookie_state
|
|
169
|
+
if state is not None:
|
|
170
|
+
self._slot.http.replace_cookies(state.snapshot())
|
|
171
|
+
|
|
172
|
+
async def sync_from_http(self) -> None:
|
|
173
|
+
"""Commit HTTP response cookie mutations, including deletions."""
|
|
174
|
+
state = self._slot.cookie_state
|
|
175
|
+
if state is not None:
|
|
176
|
+
state.replace(self._slot.http.export_cookies())
|
|
177
|
+
|
|
178
|
+
async def sync_to_browser(self, handle: Any) -> None:
|
|
179
|
+
"""Apply canonical cookies to an isolated browser context."""
|
|
180
|
+
state = self._slot.cookie_state
|
|
181
|
+
if state is None:
|
|
182
|
+
return
|
|
183
|
+
context = self._cookie_context(handle)
|
|
184
|
+
existing = await context.cookies()
|
|
185
|
+
validate_shareable_cookies(existing)
|
|
186
|
+
await context.clear_cookies()
|
|
187
|
+
cookies = cookies_for_browser(state.snapshot())
|
|
188
|
+
if cookies:
|
|
189
|
+
await context.add_cookies(cookies)
|
|
190
|
+
|
|
191
|
+
async def sync_from_browser(self, handle: Any) -> None:
|
|
192
|
+
"""Commit browser cookie mutations after process code has finished."""
|
|
193
|
+
state = self._slot.cookie_state
|
|
194
|
+
if state is None:
|
|
195
|
+
return
|
|
196
|
+
context = self._cookie_context(handle)
|
|
197
|
+
state.replace(await context.cookies())
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def _cookie_context(handle: Any) -> Any:
|
|
201
|
+
if getattr(handle, "owned", None) is not True:
|
|
202
|
+
raise ConfigurationError("Cookie sharing requires an owned browser context")
|
|
203
|
+
if "cookies" not in getattr(handle, "capabilities", frozenset()):
|
|
204
|
+
raise ConfigurationError("Browser Provider does not support cookie sharing")
|
|
205
|
+
context = getattr(handle, "context", None)
|
|
206
|
+
if context is None or any(
|
|
207
|
+
not callable(getattr(context, name, None))
|
|
208
|
+
for name in ("cookies", "clear_cookies", "add_cookies")
|
|
209
|
+
):
|
|
210
|
+
raise ConfigurationError("Browser context does not support cookie sharing")
|
|
211
|
+
return context
|
|
212
|
+
|
|
213
|
+
async def browser(self, *, required_capabilities: frozenset[str]) -> Any:
|
|
214
|
+
async with self._slot.browser_lock:
|
|
215
|
+
if self._slot.browser is None:
|
|
216
|
+
provider = self._slot.browser_provider
|
|
217
|
+
if provider is None:
|
|
218
|
+
raise ConfigurationError("A browser Provider is required for browser mode")
|
|
219
|
+
self._slot.browser = await provider.open(
|
|
220
|
+
identity=self._slot.identity, proxy=self._slot.proxy
|
|
221
|
+
)
|
|
222
|
+
handle = self._slot.browser
|
|
223
|
+
if "borrowed" in handle.capabilities:
|
|
224
|
+
self._slot.retire = True
|
|
225
|
+
failure = ConfigurationError(
|
|
226
|
+
"Borrowed browser contexts cannot be isolated as SessionPool slots"
|
|
227
|
+
)
|
|
228
|
+
try:
|
|
229
|
+
await asyncio.wait_for(handle.close(), timeout=10)
|
|
230
|
+
except asyncio.CancelledError:
|
|
231
|
+
raise
|
|
232
|
+
except Exception:
|
|
233
|
+
failure.add_note("Borrowed browser rejection cleanup also failed")
|
|
234
|
+
else:
|
|
235
|
+
self._slot.browser = None
|
|
236
|
+
raise failure
|
|
237
|
+
if required_capabilities - handle.capabilities:
|
|
238
|
+
raise ConfigurationError(
|
|
239
|
+
"Browser Provider does not support required capabilities"
|
|
240
|
+
)
|
|
241
|
+
return handle
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class SessionPool:
|
|
245
|
+
"""A bounded pool of anonymous, origin-affine acquisition generations.
|
|
246
|
+
|
|
247
|
+
A pool is single-use and binds to exactly one :class:`Crawler`. Factories are
|
|
248
|
+
called once per slot generation with safe :class:`SessionInfo`; a proxy factory
|
|
249
|
+
may return either a handle or a provider exposing ``acquire()``. A fixed handle
|
|
250
|
+
preserves configuration only and is not proof of a physically sticky exit IP.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
def __init__(
|
|
254
|
+
self,
|
|
255
|
+
*,
|
|
256
|
+
max_size: int = 4,
|
|
257
|
+
max_age: float = 300,
|
|
258
|
+
max_usage: int = 100,
|
|
259
|
+
max_error_score: int = 3,
|
|
260
|
+
browser_factory: Factory | None = None,
|
|
261
|
+
proxy_factory: Factory | None = None,
|
|
262
|
+
share_cookies: bool = False,
|
|
263
|
+
) -> None:
|
|
264
|
+
if isinstance(max_size, bool) or not isinstance(max_size, int) or max_size < 1:
|
|
265
|
+
raise ConfigurationError("max_size must be a positive integer")
|
|
266
|
+
if (
|
|
267
|
+
isinstance(max_usage, bool)
|
|
268
|
+
or not isinstance(max_usage, int)
|
|
269
|
+
or max_usage < 1
|
|
270
|
+
):
|
|
271
|
+
raise ConfigurationError("max_usage must be a positive integer")
|
|
272
|
+
if (
|
|
273
|
+
isinstance(max_error_score, bool)
|
|
274
|
+
or not isinstance(max_error_score, int)
|
|
275
|
+
or max_error_score < 1
|
|
276
|
+
):
|
|
277
|
+
raise ConfigurationError("max_error_score must be a positive integer")
|
|
278
|
+
if (
|
|
279
|
+
isinstance(max_age, bool)
|
|
280
|
+
or not isinstance(max_age, (int, float))
|
|
281
|
+
or not math.isfinite(max_age)
|
|
282
|
+
or max_age <= 0
|
|
283
|
+
):
|
|
284
|
+
raise ConfigurationError("max_age must be a finite positive number")
|
|
285
|
+
if not isinstance(share_cookies, bool):
|
|
286
|
+
raise ConfigurationError("share_cookies must be a boolean")
|
|
287
|
+
self.max_size = max_size
|
|
288
|
+
self.max_age = float(max_age)
|
|
289
|
+
self.max_usage = max_usage
|
|
290
|
+
self.max_error_score = max_error_score
|
|
291
|
+
self.browser_factory = browser_factory
|
|
292
|
+
self.proxy_factory = proxy_factory
|
|
293
|
+
self.share_cookies = share_cookies
|
|
294
|
+
self._condition = asyncio.Condition()
|
|
295
|
+
self._slots: list[_Slot] = []
|
|
296
|
+
self._retired: list[_Slot] = []
|
|
297
|
+
self._closing: set[int] = set()
|
|
298
|
+
self._orphaned: list[Any] = []
|
|
299
|
+
self._creating = 0
|
|
300
|
+
self._pending_affinities: set[tuple[str, str]] = set()
|
|
301
|
+
self._bound = False
|
|
302
|
+
self._closed = False
|
|
303
|
+
self._limits: Limits | None = None
|
|
304
|
+
self._identity: Identity | None = None
|
|
305
|
+
self._transport: httpx.AsyncBaseTransport | None = None
|
|
306
|
+
self._browser_provider: Any = None
|
|
307
|
+
self._close_resource: CloseResource | None = None
|
|
308
|
+
|
|
309
|
+
def __repr__(self) -> str:
|
|
310
|
+
return (
|
|
311
|
+
f"SessionPool(max_size={self.max_size}, max_age={self.max_age}, "
|
|
312
|
+
f"max_usage={self.max_usage}, max_error_score={self.max_error_score}, "
|
|
313
|
+
f"share_cookies={self.share_cookies})"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def bind(
|
|
317
|
+
self,
|
|
318
|
+
*,
|
|
319
|
+
limits: Limits,
|
|
320
|
+
identity: Identity,
|
|
321
|
+
transport: httpx.AsyncBaseTransport | None,
|
|
322
|
+
browser_provider: Any,
|
|
323
|
+
close_resource: CloseResource,
|
|
324
|
+
) -> None:
|
|
325
|
+
if self._bound:
|
|
326
|
+
raise ConfigurationError("A SessionPool cannot be shared by multiple Crawlers")
|
|
327
|
+
self._bound = True
|
|
328
|
+
self._limits = limits
|
|
329
|
+
self._identity = identity
|
|
330
|
+
self._transport = transport
|
|
331
|
+
self._browser_provider = browser_provider
|
|
332
|
+
self._close_resource = close_resource
|
|
333
|
+
|
|
334
|
+
@asynccontextmanager
|
|
335
|
+
async def lease(self, affinity: tuple[str, str], *, origin: str) -> AsyncIterator[SessionLease]:
|
|
336
|
+
slot = await self._acquire(affinity, origin=origin)
|
|
337
|
+
lease = SessionLease(slot)
|
|
338
|
+
try:
|
|
339
|
+
yield lease
|
|
340
|
+
finally:
|
|
341
|
+
primary_error = sys.exc_info()[1]
|
|
342
|
+
lease.session._invalidate()
|
|
343
|
+
release = asyncio.create_task(self._release(slot))
|
|
344
|
+
try:
|
|
345
|
+
await asyncio.shield(release)
|
|
346
|
+
except asyncio.CancelledError as cancellation:
|
|
347
|
+
pending_cleanup_error: BaseException | None = None
|
|
348
|
+
try:
|
|
349
|
+
if not release.done():
|
|
350
|
+
await asyncio.shield(release)
|
|
351
|
+
release.result()
|
|
352
|
+
except BaseException as caught_cleanup_error:
|
|
353
|
+
pending_cleanup_error = caught_cleanup_error
|
|
354
|
+
if pending_cleanup_error is not None:
|
|
355
|
+
if primary_error is None:
|
|
356
|
+
raise pending_cleanup_error from None
|
|
357
|
+
primary_error.add_note("Session resource cleanup also failed")
|
|
358
|
+
elif primary_error is None:
|
|
359
|
+
raise cancellation from None
|
|
360
|
+
except BaseException:
|
|
361
|
+
if primary_error is None:
|
|
362
|
+
raise
|
|
363
|
+
primary_error.add_note("Session resource cleanup also failed")
|
|
364
|
+
|
|
365
|
+
async def _acquire(self, affinity: tuple[str, str], *, origin: str) -> _Slot:
|
|
366
|
+
while True:
|
|
367
|
+
retired: _Slot | None = None
|
|
368
|
+
create = False
|
|
369
|
+
async with self._condition:
|
|
370
|
+
if self._closed:
|
|
371
|
+
raise RuntimeError("SessionPool is closed")
|
|
372
|
+
matching = next((slot for slot in self._slots if slot.affinity == affinity), None)
|
|
373
|
+
if matching is not None:
|
|
374
|
+
if matching.origin != origin:
|
|
375
|
+
raise ConfigurationError(
|
|
376
|
+
"One explicit session_key cannot be shared across origins"
|
|
377
|
+
)
|
|
378
|
+
if not matching.leased:
|
|
379
|
+
if self._expired(matching):
|
|
380
|
+
self._slots.remove(matching)
|
|
381
|
+
self._retired.append(matching)
|
|
382
|
+
self._pending_affinities.add(affinity)
|
|
383
|
+
retired = matching
|
|
384
|
+
else:
|
|
385
|
+
matching.leased = True
|
|
386
|
+
return matching
|
|
387
|
+
elif affinity in self._pending_affinities:
|
|
388
|
+
await self._condition.wait()
|
|
389
|
+
continue
|
|
390
|
+
elif len(self._slots) + self._creating < self.max_size:
|
|
391
|
+
self._creating += 1
|
|
392
|
+
self._pending_affinities.add(affinity)
|
|
393
|
+
create = True
|
|
394
|
+
else:
|
|
395
|
+
idle = [slot for slot in self._slots if not slot.leased]
|
|
396
|
+
if idle:
|
|
397
|
+
retired = min(idle, key=lambda slot: slot.last_used)
|
|
398
|
+
self._slots.remove(retired)
|
|
399
|
+
self._retired.append(retired)
|
|
400
|
+
self._creating += 1
|
|
401
|
+
self._pending_affinities.add(affinity)
|
|
402
|
+
create = True
|
|
403
|
+
if retired is None and not create:
|
|
404
|
+
await self._condition.wait()
|
|
405
|
+
continue
|
|
406
|
+
if retired is not None:
|
|
407
|
+
try:
|
|
408
|
+
await self._close_tracked(retired)
|
|
409
|
+
except BaseException as error:
|
|
410
|
+
if create:
|
|
411
|
+
cancellation, cleanup_error = await self._complete_cleanup(
|
|
412
|
+
self._release_creation_reservation(affinity)
|
|
413
|
+
)
|
|
414
|
+
if cleanup_error is not None:
|
|
415
|
+
error.add_note("Session creation reservation cleanup also failed")
|
|
416
|
+
if cancellation is not None and not isinstance(
|
|
417
|
+
error, asyncio.CancelledError
|
|
418
|
+
):
|
|
419
|
+
error.add_note("Session cleanup was also cancelled")
|
|
420
|
+
else:
|
|
421
|
+
cancellation, cleanup_error = await self._complete_cleanup(
|
|
422
|
+
self._discard_pending_affinity(affinity)
|
|
423
|
+
)
|
|
424
|
+
if cleanup_error is not None:
|
|
425
|
+
error.add_note("Session affinity cleanup also failed")
|
|
426
|
+
if cancellation is not None and not isinstance(
|
|
427
|
+
error, asyncio.CancelledError
|
|
428
|
+
):
|
|
429
|
+
error.add_note("Session cleanup was also cancelled")
|
|
430
|
+
raise
|
|
431
|
+
if not create:
|
|
432
|
+
cancellation, cleanup_error = await self._complete_cleanup(
|
|
433
|
+
self._discard_pending_affinity(affinity)
|
|
434
|
+
)
|
|
435
|
+
if cleanup_error is not None:
|
|
436
|
+
raise cleanup_error from None
|
|
437
|
+
if cancellation is not None:
|
|
438
|
+
raise cancellation from None
|
|
439
|
+
continue
|
|
440
|
+
if create:
|
|
441
|
+
try:
|
|
442
|
+
slot = await self._create_slot(affinity, origin=origin)
|
|
443
|
+
except BaseException as error:
|
|
444
|
+
cancellation, cleanup_error = await self._complete_cleanup(
|
|
445
|
+
self._release_creation_reservation(affinity)
|
|
446
|
+
)
|
|
447
|
+
if cleanup_error is not None:
|
|
448
|
+
error.add_note("Session creation reservation cleanup also failed")
|
|
449
|
+
if cancellation is not None and not isinstance(error, asyncio.CancelledError):
|
|
450
|
+
error.add_note("Session cleanup was also cancelled")
|
|
451
|
+
raise
|
|
452
|
+
try:
|
|
453
|
+
async with self._condition:
|
|
454
|
+
if self._closed:
|
|
455
|
+
close_new = True
|
|
456
|
+
else:
|
|
457
|
+
self._creating -= 1
|
|
458
|
+
self._pending_affinities.discard(affinity)
|
|
459
|
+
slot.leased = True
|
|
460
|
+
self._slots.append(slot)
|
|
461
|
+
close_new = False
|
|
462
|
+
self._condition.notify_all()
|
|
463
|
+
except BaseException as error:
|
|
464
|
+
cancellation, cleanup_error = await self._complete_cleanup(
|
|
465
|
+
self._rollback_created_slot(slot, affinity)
|
|
466
|
+
)
|
|
467
|
+
if cleanup_error is not None:
|
|
468
|
+
error.add_note("Created session rollback also failed")
|
|
469
|
+
if cancellation is not None and not isinstance(error, asyncio.CancelledError):
|
|
470
|
+
error.add_note("Session rollback was also cancelled")
|
|
471
|
+
raise
|
|
472
|
+
if close_new:
|
|
473
|
+
cancellation, cleanup_error = await self._complete_cleanup(
|
|
474
|
+
self._rollback_created_slot(slot, affinity)
|
|
475
|
+
)
|
|
476
|
+
if cleanup_error is not None:
|
|
477
|
+
raise cleanup_error from None
|
|
478
|
+
if cancellation is not None:
|
|
479
|
+
raise cancellation from None
|
|
480
|
+
raise RuntimeError("SessionPool closed while creating a session")
|
|
481
|
+
return slot
|
|
482
|
+
|
|
483
|
+
async def _release_creation_reservation(self, affinity: tuple[str, str]) -> None:
|
|
484
|
+
async with self._condition:
|
|
485
|
+
assert self._creating > 0
|
|
486
|
+
self._creating -= 1
|
|
487
|
+
self._pending_affinities.discard(affinity)
|
|
488
|
+
self._condition.notify_all()
|
|
489
|
+
|
|
490
|
+
async def _discard_pending_affinity(self, affinity: tuple[str, str]) -> None:
|
|
491
|
+
async with self._condition:
|
|
492
|
+
self._pending_affinities.discard(affinity)
|
|
493
|
+
self._condition.notify_all()
|
|
494
|
+
|
|
495
|
+
async def _rollback_created_slot(
|
|
496
|
+
self, slot: _Slot, affinity: tuple[str, str]
|
|
497
|
+
) -> None:
|
|
498
|
+
close_error: BaseException | None = None
|
|
499
|
+
try:
|
|
500
|
+
await self._close_slot(slot)
|
|
501
|
+
except BaseException as error:
|
|
502
|
+
close_error = error
|
|
503
|
+
async with self._condition:
|
|
504
|
+
if close_error is not None and slot not in self._retired:
|
|
505
|
+
self._retired.append(slot)
|
|
506
|
+
assert self._creating > 0
|
|
507
|
+
self._creating -= 1
|
|
508
|
+
self._pending_affinities.discard(affinity)
|
|
509
|
+
self._condition.notify_all()
|
|
510
|
+
if close_error is not None:
|
|
511
|
+
raise close_error from None
|
|
512
|
+
|
|
513
|
+
@staticmethod
|
|
514
|
+
async def _complete_cleanup(
|
|
515
|
+
cleanup: Coroutine[Any, Any, None],
|
|
516
|
+
) -> tuple[asyncio.CancelledError | None, BaseException | None]:
|
|
517
|
+
task: asyncio.Task[None] = asyncio.create_task(cleanup)
|
|
518
|
+
cancellation: asyncio.CancelledError | None = None
|
|
519
|
+
while not task.done():
|
|
520
|
+
try:
|
|
521
|
+
await asyncio.shield(task)
|
|
522
|
+
except asyncio.CancelledError as error:
|
|
523
|
+
cancellation = cancellation or error
|
|
524
|
+
except BaseException:
|
|
525
|
+
# The completed task's exception is classified below so caller
|
|
526
|
+
# cancellation and cleanup failure can both be preserved.
|
|
527
|
+
pass
|
|
528
|
+
try:
|
|
529
|
+
task.result()
|
|
530
|
+
except BaseException as error:
|
|
531
|
+
return cancellation, error
|
|
532
|
+
return cancellation, None
|
|
533
|
+
|
|
534
|
+
async def _create_slot(self, affinity: tuple[str, str], *, origin: str) -> _Slot:
|
|
535
|
+
assert self._limits is not None and self._identity is not None
|
|
536
|
+
session_id = uuid4().hex
|
|
537
|
+
info = SessionInfo(session_id, 0, 0, 0.0)
|
|
538
|
+
proxy = None
|
|
539
|
+
browser_provider = self._browser_provider
|
|
540
|
+
try:
|
|
541
|
+
if self.proxy_factory is not None:
|
|
542
|
+
proxy = await _resolve(self.proxy_factory(info))
|
|
543
|
+
acquire = getattr(proxy, "acquire", None)
|
|
544
|
+
if callable(acquire):
|
|
545
|
+
proxy = await _resolve(acquire())
|
|
546
|
+
if self.browser_factory is not None:
|
|
547
|
+
browser_provider = await _resolve(self.browser_factory(info))
|
|
548
|
+
self._validate_browser_provider(browser_provider)
|
|
549
|
+
identity = deepcopy(self._identity)
|
|
550
|
+
if self.share_cookies:
|
|
551
|
+
validate_shareable_cookies(
|
|
552
|
+
[*(identity.storage_state or {}).get("cookies", []), *identity.cookies]
|
|
553
|
+
)
|
|
554
|
+
http = HttpSession(
|
|
555
|
+
self._limits,
|
|
556
|
+
identity,
|
|
557
|
+
proxy_url=proxy.http_url() if proxy is not None else None,
|
|
558
|
+
transport=self._transport,
|
|
559
|
+
close_transport=self._transport is None,
|
|
560
|
+
reject_partitioned=self.share_cookies,
|
|
561
|
+
)
|
|
562
|
+
except BaseException as error:
|
|
563
|
+
cleanup_failed = False
|
|
564
|
+
if proxy is not None:
|
|
565
|
+
cancellation, cleanup_error = await self._complete_cleanup(
|
|
566
|
+
self._close_one(proxy)
|
|
567
|
+
)
|
|
568
|
+
if cleanup_error is not None:
|
|
569
|
+
cleanup_failed = True
|
|
570
|
+
_, orphan_error = await self._complete_cleanup(
|
|
571
|
+
self._track_orphan(proxy)
|
|
572
|
+
)
|
|
573
|
+
cleanup_failed = orphan_error is not None or cleanup_failed
|
|
574
|
+
if cancellation is not None and not isinstance(error, asyncio.CancelledError):
|
|
575
|
+
error.add_note("Partial session cleanup was also cancelled")
|
|
576
|
+
if not isinstance(error, Exception):
|
|
577
|
+
if cleanup_failed:
|
|
578
|
+
error.add_note("Partial session resource cleanup also failed")
|
|
579
|
+
raise
|
|
580
|
+
failure = ConfigurationError("Session resource creation failed")
|
|
581
|
+
if cleanup_failed:
|
|
582
|
+
failure.add_note("Partial session resource cleanup also failed")
|
|
583
|
+
raise failure from None
|
|
584
|
+
return _Slot(
|
|
585
|
+
session_id,
|
|
586
|
+
affinity,
|
|
587
|
+
origin,
|
|
588
|
+
identity,
|
|
589
|
+
proxy,
|
|
590
|
+
http,
|
|
591
|
+
browser_provider,
|
|
592
|
+
cookie_state=CookieState.from_jar(http.client.cookies.jar)
|
|
593
|
+
if self.share_cookies
|
|
594
|
+
else None,
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
async def _release(self, slot: _Slot) -> None:
|
|
598
|
+
close = False
|
|
599
|
+
async with self._condition:
|
|
600
|
+
slot.use_count += 1
|
|
601
|
+
slot.last_used = time.monotonic()
|
|
602
|
+
slot.leased = False
|
|
603
|
+
if self._closed or self._expired(slot):
|
|
604
|
+
if slot in self._slots:
|
|
605
|
+
self._slots.remove(slot)
|
|
606
|
+
if slot not in self._retired:
|
|
607
|
+
self._retired.append(slot)
|
|
608
|
+
close = True
|
|
609
|
+
self._condition.notify_all()
|
|
610
|
+
if close:
|
|
611
|
+
await self._close_tracked(slot)
|
|
612
|
+
|
|
613
|
+
def _expired(self, slot: _Slot) -> bool:
|
|
614
|
+
return (
|
|
615
|
+
slot.retire
|
|
616
|
+
or time.monotonic() - slot.created_at >= self.max_age
|
|
617
|
+
or slot.use_count >= self.max_usage
|
|
618
|
+
or slot.error_score >= self.max_error_score
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
async def retire_idle(self) -> None:
|
|
622
|
+
"""Retire all currently idle generations; active leases finish normally."""
|
|
623
|
+
async with self._condition:
|
|
624
|
+
idle = [slot for slot in self._slots if not slot.leased]
|
|
625
|
+
for slot in idle:
|
|
626
|
+
self._slots.remove(slot)
|
|
627
|
+
self._retired.append(slot)
|
|
628
|
+
self._condition.notify_all()
|
|
629
|
+
errors = await asyncio.gather(
|
|
630
|
+
*(self._close_tracked(slot) for slot in idle), return_exceptions=True
|
|
631
|
+
)
|
|
632
|
+
if any(isinstance(error, BaseException) for error in errors):
|
|
633
|
+
raise FetchError("One or more session resources failed to close") from None
|
|
634
|
+
|
|
635
|
+
async def close(self) -> None:
|
|
636
|
+
async with self._condition:
|
|
637
|
+
self._closed = True
|
|
638
|
+
self._condition.notify_all()
|
|
639
|
+
while (
|
|
640
|
+
any(slot.leased for slot in self._slots)
|
|
641
|
+
or self._creating
|
|
642
|
+
or self._closing
|
|
643
|
+
):
|
|
644
|
+
await self._condition.wait()
|
|
645
|
+
for slot in self._slots:
|
|
646
|
+
if slot not in self._retired:
|
|
647
|
+
self._retired.append(slot)
|
|
648
|
+
slots = list(self._retired)
|
|
649
|
+
orphaned = list(self._orphaned)
|
|
650
|
+
self._slots = []
|
|
651
|
+
self._condition.notify_all()
|
|
652
|
+
errors = await asyncio.gather(
|
|
653
|
+
*(self._close_tracked(slot) for slot in slots),
|
|
654
|
+
*(self._close_orphan(resource) for resource in orphaned),
|
|
655
|
+
return_exceptions=True,
|
|
656
|
+
)
|
|
657
|
+
if any(isinstance(error, BaseException) for error in errors):
|
|
658
|
+
raise FetchError("One or more session resources failed to close") from None
|
|
659
|
+
|
|
660
|
+
async def _close_slot(self, slot: _Slot) -> None:
|
|
661
|
+
errors: list[BaseException] = []
|
|
662
|
+
for resource in (slot.browser, slot.http, slot.proxy):
|
|
663
|
+
try:
|
|
664
|
+
await self._close_one(resource)
|
|
665
|
+
except BaseException as error:
|
|
666
|
+
errors.append(error)
|
|
667
|
+
if errors:
|
|
668
|
+
raise FetchError("Session resource cleanup failed") from None
|
|
669
|
+
|
|
670
|
+
async def _close_tracked(self, slot: _Slot) -> None:
|
|
671
|
+
async with self._condition:
|
|
672
|
+
while id(slot) in self._closing:
|
|
673
|
+
await self._condition.wait()
|
|
674
|
+
if slot not in self._retired:
|
|
675
|
+
return
|
|
676
|
+
self._closing.add(id(slot))
|
|
677
|
+
cancellation, close_error = await self._complete_cleanup(self._close_slot(slot))
|
|
678
|
+
finalize_cancellation, finalize_error = await self._complete_cleanup(
|
|
679
|
+
self._finish_tracked_close(slot, succeeded=close_error is None)
|
|
680
|
+
)
|
|
681
|
+
cancellation = cancellation or finalize_cancellation
|
|
682
|
+
if finalize_error is not None:
|
|
683
|
+
raise finalize_error from None
|
|
684
|
+
if close_error is not None:
|
|
685
|
+
raise close_error from None
|
|
686
|
+
if cancellation is not None:
|
|
687
|
+
raise cancellation from None
|
|
688
|
+
|
|
689
|
+
async def _finish_tracked_close(self, slot: _Slot, *, succeeded: bool) -> None:
|
|
690
|
+
async with self._condition:
|
|
691
|
+
if succeeded and slot in self._retired:
|
|
692
|
+
self._retired.remove(slot)
|
|
693
|
+
self._closing.discard(id(slot))
|
|
694
|
+
self._condition.notify_all()
|
|
695
|
+
|
|
696
|
+
async def _track_orphan(self, resource: Any) -> None:
|
|
697
|
+
async with self._condition:
|
|
698
|
+
self._orphaned.append(resource)
|
|
699
|
+
|
|
700
|
+
def _validate_browser_provider(self, provider: Any) -> None:
|
|
701
|
+
if provider is None:
|
|
702
|
+
return
|
|
703
|
+
from .providers.browser import CDPBrowser
|
|
704
|
+
|
|
705
|
+
if isinstance(provider, CDPBrowser):
|
|
706
|
+
raise ConfigurationError(
|
|
707
|
+
"Borrowed CDP browsers cannot be isolated as SessionPool slots"
|
|
708
|
+
)
|
|
709
|
+
if (
|
|
710
|
+
getattr(provider, "_dataapp_crawler_provider", None) == "relay"
|
|
711
|
+
and getattr(provider, "_user_data_dir", None) is not None
|
|
712
|
+
):
|
|
713
|
+
raise ConfigurationError(
|
|
714
|
+
"Persistent Relay user_data_dir is unavailable in SessionPool mode"
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
async def _close_one(self, resource: Any) -> None:
|
|
718
|
+
if resource is None:
|
|
719
|
+
return
|
|
720
|
+
if self._close_resource is not None:
|
|
721
|
+
await self._close_resource(resource)
|
|
722
|
+
return
|
|
723
|
+
closer = getattr(resource, "close", None) or getattr(resource, "aclose", None)
|
|
724
|
+
if not callable(closer):
|
|
725
|
+
raise TypeError("Session resource exposes no close method")
|
|
726
|
+
await _resolve(closer())
|
|
727
|
+
|
|
728
|
+
async def _close_orphan(self, resource: Any) -> None:
|
|
729
|
+
await self._close_one(resource)
|
|
730
|
+
async with self._condition:
|
|
731
|
+
self._orphaned = [item for item in self._orphaned if item is not resource]
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
async def _resolve(value: Any) -> Any:
|
|
735
|
+
return await value if inspect.isawaitable(value) else value
|