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.
@@ -0,0 +1,451 @@
1
+ """Bounded native Playwright process pool with isolated per-open contexts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import math
7
+ import time
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, TypeVar
10
+
11
+ from ..errors import ConfigurationError
12
+ from ..models import Identity
13
+ from .browser import PlaywrightBrowser
14
+ from .proxy import ProxyHandle
15
+
16
+ _CAPABILITIES = frozenset(
17
+ {"page", "cookies", "origin_headers", "proxy", "storage_state", "browser_pool"}
18
+ )
19
+ T = TypeVar("T")
20
+
21
+
22
+ async def _await_invariant(task: asyncio.Task[T]) -> tuple[T, asyncio.CancelledError | None]:
23
+ """Finish an ownership transition even if its caller is cancelled repeatedly."""
24
+
25
+ cancelled: asyncio.CancelledError | None = None
26
+ while not task.done():
27
+ try:
28
+ await asyncio.shield(task)
29
+ except asyncio.CancelledError as error:
30
+ cancelled = cancelled or error
31
+ return task.result(), cancelled
32
+
33
+
34
+ def _positive_int(name: str, value: int) -> int:
35
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
36
+ raise ConfigurationError(f"{name} must be a positive integer")
37
+ return value
38
+
39
+
40
+ def _positive_timeout(name: str, value: float) -> float:
41
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
42
+ raise ConfigurationError(f"{name} must be finite and positive")
43
+ result = float(value)
44
+ if not math.isfinite(result) or result <= 0:
45
+ raise ConfigurationError(f"{name} must be finite and positive")
46
+ return result
47
+
48
+
49
+ @dataclass(eq=False)
50
+ class _Process:
51
+ playwright: Any = field(repr=False)
52
+ browser: Any = field(repr=False)
53
+ contexts: int = 0
54
+ close_task: asyncio.Task[None] | None = field(default=None, repr=False)
55
+
56
+ def connected(self) -> bool:
57
+ check = getattr(self.browser, "is_connected", None)
58
+ if not callable(check):
59
+ return True
60
+ try:
61
+ return bool(check())
62
+ except Exception:
63
+ return False
64
+
65
+
66
+ class PooledBrowserHandle:
67
+ """One isolated context leased from :class:`BrowserPool`."""
68
+
69
+ provider = "playwright_pool"
70
+ capabilities = _CAPABILITIES
71
+ owned = True
72
+
73
+ def __init__(
74
+ self,
75
+ pool: BrowserPool,
76
+ process: _Process,
77
+ context: Any,
78
+ *,
79
+ max_pages: int,
80
+ page_acquire_timeout: float,
81
+ ) -> None:
82
+ self.context = context
83
+ self._pool = pool
84
+ self._process = process
85
+ self._page_slots = asyncio.Semaphore(max_pages)
86
+ self._page_acquire_timeout = page_acquire_timeout
87
+ self._pages: list[Any] = []
88
+ self._state_lock = asyncio.Lock()
89
+ self._closed = False
90
+ self._close_task: asyncio.Task[None] | None = None
91
+
92
+ async def new_page(self) -> Any:
93
+ acquired = False
94
+ try:
95
+ try:
96
+ async with asyncio.timeout(self._page_acquire_timeout):
97
+ await self._page_slots.acquire()
98
+ except TimeoutError:
99
+ raise ConfigurationError(
100
+ "BrowserPool page capacity acquisition timed out"
101
+ ) from None
102
+ acquired = True
103
+
104
+ async with self._state_lock:
105
+ if self._closed:
106
+ raise RuntimeError("Browser handle is closed")
107
+ page = await self.context.new_page()
108
+ self._pages.append(page)
109
+ acquired = False
110
+ return page
111
+ finally:
112
+ if acquired:
113
+ self._page_slots.release()
114
+
115
+ async def close_page(self, page: Any) -> None:
116
+ cancelled: asyncio.CancelledError | None = None
117
+ failure: BaseException | None = None
118
+ async with self._state_lock:
119
+ if page not in self._pages:
120
+ return
121
+ close_task = asyncio.create_task(asyncio.wait_for(page.close(), timeout=5))
122
+ try:
123
+ await asyncio.shield(close_task)
124
+ except asyncio.CancelledError as error:
125
+ cancelled = error
126
+ try:
127
+ await asyncio.shield(close_task)
128
+ except BaseException as close_error:
129
+ failure = close_error
130
+ except BaseException as error:
131
+ failure = error
132
+ if failure is None:
133
+ self._pages.remove(page)
134
+ self._page_slots.release()
135
+
136
+ if failure is not None:
137
+ # A page that cannot be closed makes this context unsafe to reuse. The
138
+ # handle close task retries bounded page cleanup, then always closes the
139
+ # context and returns its pool reservation.
140
+ try:
141
+ await self.close()
142
+ except BaseException:
143
+ pass
144
+ if cancelled is not None:
145
+ raise cancelled
146
+ raise ConfigurationError("Browser page cleanup failed") from None
147
+ if cancelled is not None:
148
+ raise cancelled
149
+
150
+ async def close(self) -> None:
151
+ async with self._state_lock:
152
+ if self._close_task is None:
153
+ self._closed = True
154
+ pages = list(reversed(self._pages))
155
+ self._pages.clear()
156
+ self._close_task = asyncio.create_task(self._close_impl(pages))
157
+ task = self._close_task
158
+ await asyncio.shield(task)
159
+
160
+ async def _close_impl(self, pages: list[Any]) -> None:
161
+ first_error: BaseException | None = None
162
+ context_failed = False
163
+ for page in pages:
164
+ try:
165
+ await asyncio.wait_for(page.close(), timeout=5)
166
+ except BaseException as error:
167
+ first_error = first_error or error
168
+ try:
169
+ await asyncio.wait_for(self.context.close(), timeout=5)
170
+ except BaseException as error:
171
+ context_failed = True
172
+ first_error = first_error or error
173
+ finally:
174
+ await self._pool._release(self._process, self, retire=context_failed)
175
+ if first_error is not None:
176
+ if not isinstance(first_error, Exception):
177
+ raise first_error
178
+ raise ConfigurationError("Pooled browser context cleanup failed") from None
179
+
180
+
181
+ class BrowserPool:
182
+ """Share bounded native processes while isolating each open in its own context.
183
+
184
+ The pool owns browser processes and Playwright clients. Handles own only their
185
+ context and pages. Close all handles (normally by closing the Crawler or
186
+ SessionPool) before closing this pool. ``capacity`` is the maximum number of
187
+ simultaneous contexts; a SessionPool that keeps idle browser handles should not
188
+ have a larger ``max_size`` unless finite acquisition failures are acceptable.
189
+ """
190
+
191
+ def __init__(
192
+ self,
193
+ provider: PlaywrightBrowser,
194
+ *,
195
+ max_browsers: int = 2,
196
+ max_contexts_per_browser: int = 4,
197
+ max_pages_per_context: int = 1,
198
+ acquire_timeout: float = 30,
199
+ page_acquire_timeout: float = 30,
200
+ ) -> None:
201
+ if not isinstance(provider, PlaywrightBrowser):
202
+ raise ConfigurationError("BrowserPool requires a PlaywrightBrowser provider")
203
+ self.provider = provider
204
+ self.max_browsers = _positive_int("max_browsers", max_browsers)
205
+ self.max_contexts_per_browser = _positive_int(
206
+ "max_contexts_per_browser", max_contexts_per_browser
207
+ )
208
+ self.max_pages_per_context = _positive_int(
209
+ "max_pages_per_context", max_pages_per_context
210
+ )
211
+ self.acquire_timeout = _positive_timeout("acquire_timeout", acquire_timeout)
212
+ self.page_acquire_timeout = _positive_timeout(
213
+ "page_acquire_timeout", page_acquire_timeout
214
+ )
215
+ self._condition = asyncio.Condition()
216
+ self._processes: list[_Process] = []
217
+ self._retiring: set[_Process] = set()
218
+ self._handles: set[PooledBrowserHandle] = set()
219
+ self._starting = 0
220
+ self._closed = False
221
+ self._close_task: asyncio.Task[None] | None = None
222
+
223
+ @property
224
+ def capacity(self) -> int:
225
+ return self.max_browsers * self.max_contexts_per_browser
226
+
227
+ async def open(
228
+ self, *, identity: Identity | None = None, proxy: ProxyHandle | None = None
229
+ ) -> PooledBrowserHandle:
230
+ process = await self._reserve()
231
+ try:
232
+ context = await self.provider._open_context(
233
+ process.browser, identity=identity, proxy=proxy
234
+ )
235
+ except BaseException as error:
236
+ cleanup = asyncio.create_task(
237
+ self._release_reservation(process, retire=not process.connected())
238
+ )
239
+ await _await_invariant(cleanup)
240
+ raise error
241
+
242
+ handle = PooledBrowserHandle(
243
+ self,
244
+ process,
245
+ context,
246
+ max_pages=self.max_pages_per_context,
247
+ page_acquire_timeout=self.page_acquire_timeout,
248
+ )
249
+ try:
250
+ async with self._condition:
251
+ if not self._closed:
252
+ self._handles.add(handle)
253
+ return handle
254
+ except BaseException as error:
255
+ cleanup = asyncio.create_task(handle.close())
256
+ await _await_invariant(cleanup)
257
+ raise error
258
+ cleanup = asyncio.create_task(handle.close())
259
+ await _await_invariant(cleanup)
260
+ raise ConfigurationError("BrowserPool is closed")
261
+
262
+ async def _reserve(self) -> _Process:
263
+ deadline = time.monotonic() + self.acquire_timeout
264
+ while True:
265
+ stale: list[_Process] = []
266
+ selected: _Process | None = None
267
+ start = False
268
+ async with self._condition:
269
+ if self._closed:
270
+ raise ConfigurationError("BrowserPool is closed")
271
+ for process in list(self._processes):
272
+ if not process.connected():
273
+ self._processes.remove(process)
274
+ self._retiring.add(process)
275
+ stale.append(process)
276
+ for process in self._processes:
277
+ if process.contexts < self.max_contexts_per_browser:
278
+ process.contexts += 1
279
+ selected = process
280
+ break
281
+ if selected is None:
282
+ if len(self._processes) + self._starting < self.max_browsers:
283
+ self._starting += 1
284
+ start = True
285
+ elif not stale:
286
+ remaining = deadline - time.monotonic()
287
+ if remaining <= 0:
288
+ raise ConfigurationError(
289
+ "BrowserPool context capacity acquisition timed out"
290
+ )
291
+ try:
292
+ async with asyncio.timeout(remaining):
293
+ await self._condition.wait()
294
+ except TimeoutError:
295
+ raise ConfigurationError(
296
+ "BrowserPool context capacity acquisition timed out"
297
+ ) from None
298
+ if stale:
299
+ try:
300
+ cleanup = asyncio.create_task(self._close_stale_processes(stale))
301
+ _, cancelled = await _await_invariant(cleanup)
302
+ except BaseException as error:
303
+ rollback = asyncio.create_task(self._rollback_reserve(selected, start=start))
304
+ await _await_invariant(rollback)
305
+ raise error
306
+ if cancelled is not None:
307
+ rollback = asyncio.create_task(self._rollback_reserve(selected, start=start))
308
+ await _await_invariant(rollback)
309
+ raise cancelled
310
+ if selected is not None:
311
+ return selected
312
+ if not start:
313
+ continue
314
+
315
+ try:
316
+ playwright, browser = await self.provider._launch()
317
+ process = _Process(playwright=playwright, browser=browser, contexts=1)
318
+ except BaseException as error:
319
+ cleanup = asyncio.create_task(self._finish_failed_start())
320
+ await _await_invariant(cleanup)
321
+ raise error
322
+ try:
323
+ async with self._condition:
324
+ if not self._closed:
325
+ self._starting -= 1
326
+ self._processes.append(process)
327
+ self._condition.notify_all()
328
+ return process
329
+ except BaseException as error:
330
+ cleanup = asyncio.create_task(self._abandon_started_process(process))
331
+ await _await_invariant(cleanup)
332
+ raise error
333
+ cleanup = asyncio.create_task(self._abandon_started_process(process))
334
+ await _await_invariant(cleanup)
335
+ raise ConfigurationError("BrowserPool is closed")
336
+
337
+ async def _finish_failed_start(self) -> None:
338
+ async with self._condition:
339
+ self._starting -= 1
340
+ self._condition.notify_all()
341
+
342
+ async def _rollback_reserve(self, selected: _Process | None, *, start: bool) -> None:
343
+ async with self._condition:
344
+ if selected is not None:
345
+ selected.contexts = max(0, selected.contexts - 1)
346
+ if start:
347
+ self._starting -= 1
348
+ self._condition.notify_all()
349
+
350
+ async def _abandon_started_process(self, process: _Process) -> None:
351
+ try:
352
+ await self._close_process(process)
353
+ finally:
354
+ await self._finish_failed_start()
355
+
356
+ async def _close_stale_processes(self, processes: list[_Process]) -> None:
357
+ first_error: BaseException | None = None
358
+ for process in processes:
359
+ try:
360
+ await self._retire_process(process)
361
+ except BaseException as error:
362
+ first_error = first_error or error
363
+ if first_error is not None:
364
+ raise first_error
365
+
366
+ async def _release(
367
+ self,
368
+ process: _Process,
369
+ handle: PooledBrowserHandle,
370
+ *,
371
+ retire: bool,
372
+ ) -> None:
373
+ async with self._condition:
374
+ self._handles.discard(handle)
375
+ await self._release_reservation(process, retire=retire)
376
+
377
+ async def _release_reservation(self, process: _Process, *, retire: bool) -> None:
378
+ close = False
379
+ async with self._condition:
380
+ process.contexts = max(0, process.contexts - 1)
381
+ if (retire or not process.connected()) and process in self._processes:
382
+ self._processes.remove(process)
383
+ self._retiring.add(process)
384
+ close = True
385
+ self._condition.notify_all()
386
+ if close:
387
+ await self._retire_process(process)
388
+
389
+ async def close(self) -> None:
390
+ async with self._condition:
391
+ if self._close_task is None:
392
+ self._closed = True
393
+ self._condition.notify_all()
394
+ self._close_task = asyncio.create_task(self._close_impl())
395
+ task = self._close_task
396
+ await asyncio.shield(task)
397
+
398
+ async def _close_impl(self) -> None:
399
+ first_error: BaseException | None = None
400
+ while True:
401
+ async with self._condition:
402
+ handles = list(self._handles)
403
+ for handle in handles:
404
+ try:
405
+ await handle.close()
406
+ except BaseException as error:
407
+ first_error = first_error or error
408
+ async with self._condition:
409
+ if self._starting == 0 and not self._retiring:
410
+ break
411
+ await self._condition.wait_for(
412
+ lambda: self._starting == 0 and not self._retiring
413
+ )
414
+
415
+ async with self._condition:
416
+ processes = list(self._processes)
417
+ self._processes.clear()
418
+ for process in processes:
419
+ try:
420
+ await self._close_process(process)
421
+ except BaseException as error:
422
+ first_error = first_error or error
423
+ if first_error is not None:
424
+ if not isinstance(first_error, Exception):
425
+ raise first_error
426
+ raise ConfigurationError("BrowserPool resource cleanup failed") from None
427
+
428
+ async def _close_process(self, process: _Process) -> None:
429
+ if process.close_task is None:
430
+ process.close_task = asyncio.create_task(self._close_process_impl(process))
431
+ await asyncio.shield(process.close_task)
432
+
433
+ async def _retire_process(self, process: _Process) -> None:
434
+ try:
435
+ await self._close_process(process)
436
+ finally:
437
+ async with self._condition:
438
+ self._retiring.discard(process)
439
+ self._condition.notify_all()
440
+
441
+ async def _close_process_impl(self, process: _Process) -> None:
442
+ first_error: BaseException | None = None
443
+ for closer in (process.browser.close, process.playwright.stop):
444
+ try:
445
+ await asyncio.wait_for(closer(), timeout=5)
446
+ except BaseException as error:
447
+ first_error = first_error or error
448
+ if first_error is not None:
449
+ if not isinstance(first_error, Exception):
450
+ raise first_error
451
+ raise ConfigurationError("Pooled browser process cleanup failed") from None
@@ -0,0 +1,209 @@
1
+ """Proxy providers with deliberately small, capability-based lifecycles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Awaitable, Callable, Mapping
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Protocol
9
+ from urllib.parse import quote, unquote, urlsplit, urlunsplit
10
+
11
+ from ..errors import ConfigurationError, LimitReached
12
+
13
+ Rotate = Callable[[], Awaitable[Any]]
14
+ Release = Callable[[], Awaitable[str]]
15
+
16
+
17
+ def _proxy_parts(url: str) -> tuple[str, str | None, str | None]:
18
+ parts = urlsplit(url)
19
+ if parts.scheme not in {"http", "https", "socks5"} or not parts.hostname:
20
+ raise ConfigurationError("Proxy URL must be an absolute HTTP(S) or SOCKS5 URL")
21
+ host = f"[{parts.hostname}]" if ":" in parts.hostname else parts.hostname
22
+ netloc = host + (f":{parts.port}" if parts.port is not None else "")
23
+ server = urlunsplit((parts.scheme, netloc, "", "", ""))
24
+ return (
25
+ server,
26
+ unquote(parts.username) if parts.username else None,
27
+ (unquote(parts.password) if parts.password else None),
28
+ )
29
+
30
+
31
+ def _proxy_url(value: Any) -> tuple[str, str | None, int | None, str | None]:
32
+ """Normalize the public ProxyUrl shape without importing its package."""
33
+ if isinstance(value, Mapping):
34
+ get = value.get
35
+ else:
36
+
37
+ def get(name: str, default: Any = None) -> Any:
38
+ return getattr(value, name, default)
39
+
40
+ raw = get("url")
41
+ username = get("username")
42
+ password = get("password")
43
+ hostname = get("hostname")
44
+ port = get("port")
45
+ if raw:
46
+ parts = urlsplit(str(raw))
47
+ try:
48
+ raw_port = parts.port
49
+ except ValueError:
50
+ raise ConfigurationError("Proxy provider returned an invalid connection port") from None
51
+ if (
52
+ parts.scheme not in {"http", "https", "socks5"}
53
+ or not parts.hostname
54
+ or parts.path not in {"", "/"}
55
+ or parts.query
56
+ or parts.fragment
57
+ ):
58
+ raise ConfigurationError("Proxy provider returned an invalid connection URL")
59
+ raw_username = unquote(parts.username) if parts.username is not None else username
60
+ raw_password = unquote(parts.password) if parts.password is not None else password
61
+ raw = _build_proxy_url(
62
+ parts.scheme,
63
+ parts.hostname,
64
+ raw_port,
65
+ raw_username,
66
+ raw_password,
67
+ )
68
+ return (
69
+ raw,
70
+ get("tunnel_id") or get("tunnelId"),
71
+ get("version"),
72
+ raw_username,
73
+ )
74
+ if not hostname or not port:
75
+ raise ConfigurationError("Proxy provider returned no usable connection address")
76
+ return (
77
+ _build_proxy_url("http", str(hostname), port, username, password),
78
+ (get("tunnel_id") or get("tunnelId")),
79
+ get("version"),
80
+ username,
81
+ )
82
+
83
+
84
+ def _build_proxy_url(
85
+ scheme: str,
86
+ hostname: str,
87
+ port: Any,
88
+ username: Any,
89
+ password: Any,
90
+ ) -> str:
91
+ if isinstance(port, bool) or (port is not None and not isinstance(port, int)):
92
+ raise ConfigurationError("Proxy provider returned an invalid connection port")
93
+ if port is not None and not 1 <= port <= 65535:
94
+ raise ConfigurationError("Proxy provider returned an invalid connection port")
95
+ if password is not None and username is None:
96
+ raise ConfigurationError("Proxy provider returned a password without a username")
97
+ host = f"[{hostname}]" if ":" in hostname else hostname
98
+ auth = quote(str(username), safe="") if username is not None else ""
99
+ if password is not None:
100
+ auth += ":" + quote(str(password), safe="")
101
+ netloc = (f"{auth}@" if auth else "") + host + (f":{port}" if port else "")
102
+ return urlunsplit((scheme, netloc, "", "", ""))
103
+
104
+
105
+ @dataclass
106
+ class ProxyHandle:
107
+ """One proxy connection. Closing only releases this local handle."""
108
+
109
+ provider: str
110
+ _url: str = field(repr=False)
111
+ session_id: str | None = None
112
+ tunnel_id: str | None = None
113
+ version: int | None = None
114
+ max_rotations: int = 3
115
+ release_status: str = field(default="active", init=False)
116
+ _rotate: Rotate | None = field(default=None, repr=False)
117
+ _release: Release | None = field(default=None, repr=False)
118
+ _closed: bool = field(default=False, init=False, repr=False)
119
+ _rotation_attempts: int = field(default=0, init=False, repr=False)
120
+ _rotation_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False)
121
+
122
+ def __post_init__(self) -> None:
123
+ _proxy_parts(self._url)
124
+ if (
125
+ isinstance(self.max_rotations, bool)
126
+ or not isinstance(self.max_rotations, int)
127
+ or self.max_rotations < 0
128
+ ):
129
+ raise ConfigurationError("max_rotations must be a nonnegative integer")
130
+
131
+ @property
132
+ def closed(self) -> bool:
133
+ return self._closed
134
+
135
+ def http_url(self) -> str:
136
+ if self._closed:
137
+ raise RuntimeError("Proxy handle is closed")
138
+ return self._url
139
+
140
+ def as_playwright(self) -> dict[str, str]:
141
+ if self._closed:
142
+ raise RuntimeError("Proxy handle is closed")
143
+ server, username, password = _proxy_parts(self._url)
144
+ result = {"server": server}
145
+ if username is not None:
146
+ result["username"] = username
147
+ if password is not None:
148
+ result["password"] = password
149
+ return result
150
+
151
+ async def rotate(self) -> None:
152
+ async with self._rotation_lock:
153
+ if self._closed:
154
+ raise RuntimeError("Proxy handle is closed")
155
+ if self._rotate is None:
156
+ raise ConfigurationError(
157
+ f"Proxy provider {self.provider!r} does not support rotation"
158
+ )
159
+ if self._rotation_attempts >= self.max_rotations:
160
+ raise LimitReached("Proxy rotation attempt limit reached")
161
+ self._rotation_attempts += 1
162
+ value = await self._rotate()
163
+ try:
164
+ url, tunnel_id, version, _ = _proxy_url(value)
165
+ _proxy_parts(url)
166
+ new_version = int(version) if version is not None else self.version
167
+ except Exception:
168
+ raise ConfigurationError("Proxy rotation returned an invalid connection") from None
169
+ self._url = url
170
+ self.tunnel_id = tunnel_id or self.tunnel_id
171
+ self.version = new_version
172
+
173
+ async def close(self) -> None:
174
+ """Release local use; this does not claim to revoke upstream credentials."""
175
+ if self._closed:
176
+ return
177
+ self._closed = True
178
+ if self._release is None:
179
+ self.release_status = "not_applicable"
180
+ return
181
+ try:
182
+ self.release_status = await self._release()
183
+ except asyncio.CancelledError:
184
+ self.release_status = "failed"
185
+ raise
186
+ except Exception as error:
187
+ self.release_status = "failed"
188
+ raise ConfigurationError(
189
+ f"Proxy client release failed ({type(error).__name__})"
190
+ ) from None
191
+
192
+
193
+ class ProxyProvider(Protocol):
194
+ """Acquire a connection lease from a developer-owned proxy service or pool.
195
+
196
+ Selection and renewal belong to the provider. A handle stays associated
197
+ with its browser/session until that session is retired; this interface
198
+ does not promise a new exit IP for every HTTP request.
199
+ """
200
+
201
+ async def acquire(self) -> ProxyHandle: ...
202
+
203
+
204
+ @dataclass(frozen=True)
205
+ class StaticProxy:
206
+ url: str = field(repr=False)
207
+
208
+ async def acquire(self) -> ProxyHandle:
209
+ return ProxyHandle(provider="static", _url=self.url)
File without changes