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,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)
|
dataapp_crawler/py.typed
ADDED
|
File without changes
|