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,1392 @@
1
+ """Three public entrances: fetch, browser session and a bounded in-memory crawl."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import logging
8
+ import sys
9
+ import time
10
+ from collections import deque
11
+ from collections.abc import AsyncGenerator, AsyncIterator, Callable, Iterable
12
+ from contextlib import asynccontextmanager
13
+ from contextvars import ContextVar
14
+ from datetime import UTC, datetime
15
+ from email.utils import parsedate_to_datetime
16
+ from pathlib import Path
17
+ from typing import Any
18
+ from uuid import uuid4
19
+
20
+ import httpx
21
+
22
+ from ._logging import (
23
+ failure_context,
24
+ failure_details,
25
+ get_logger,
26
+ provider_name,
27
+ root_page_host,
28
+ target_host,
29
+ )
30
+ from ._network_diagnostics import observe_network
31
+ from .checkpoint import CrawlCheckpoint
32
+ from .context import CrawlContext, SiteGate
33
+ from .errors import (
34
+ ConfigurationError,
35
+ CrawlCancelled,
36
+ FetchError,
37
+ LimitReached,
38
+ ProcessingError,
39
+ QueueFull,
40
+ ResponseTooLarge,
41
+ browser_error_code,
42
+ http_error_code,
43
+ process_error_code,
44
+ )
45
+ from .http import HttpSession
46
+ from .models import CaptureResult, CrawlItem, Failure, Identity, Limits, Request, Stats, origin
47
+ from .scheduling import AdaptivePolicy, AutoConcurrency
48
+ from .sessions import SessionLease, SessionPool
49
+
50
+ Process = Callable[[CaptureResult, CrawlContext], Any]
51
+ BrowserCollect = Callable[[Any, CrawlContext], Any]
52
+ NavigationHook = Callable[[Request, Any, CrawlContext], Any]
53
+ FailureHook = Callable[[Failure, CrawlContext], Any]
54
+ _RETRYABLE_STATUSES = frozenset({429, 500, 502, 503, 504})
55
+
56
+
57
+ def _safe_exception_type(error: Exception) -> str:
58
+ """Return a bounded class name without retaining exception text."""
59
+ name = type(error).__name__
60
+ if len(name) <= 80 and name.isascii() and name.isidentifier():
61
+ return name
62
+ return "Exception"
63
+
64
+
65
+ class _AcquisitionScope:
66
+ __slots__ = ("crawler_id", "active")
67
+
68
+ def __init__(self, crawler_id: int) -> None:
69
+ self.crawler_id = crawler_id
70
+ self.active = True
71
+
72
+
73
+ _ACQUISITION_SCOPES: ContextVar[tuple[_AcquisitionScope, ...]] = ContextVar(
74
+ "dataapp_crawler_acquisition_scopes", default=()
75
+ )
76
+
77
+
78
+ class _SessionFetchScope:
79
+ __slots__ = ("crawler_id", "active")
80
+
81
+ def __init__(self, crawler_id: int) -> None:
82
+ self.crawler_id = crawler_id
83
+ self.active = True
84
+
85
+
86
+ _SESSION_FETCH_SCOPES: ContextVar[tuple[_SessionFetchScope, ...]] = ContextVar(
87
+ "dataapp_crawler_session_fetch_scopes", default=()
88
+ )
89
+
90
+
91
+ class _RecursivePooledFetch(ConfigurationError):
92
+ pass
93
+
94
+
95
+ class Crawler:
96
+ """Acquire HTTP or browser content under shared limits and lifecycle control.
97
+
98
+ ``fetch`` handles one request, ``crawl`` owns a bounded in-memory queue, and
99
+ ``browser`` exposes an explicit manual browser session. ``run_browser``
100
+ passes a native Page to a developer flow under cancellation and time budgets.
101
+ Queue checkpoints are values only: callers or the Runtime remain responsible for persisting
102
+ them as private artifacts. Concise INFO lifecycle, result and failure logs go
103
+ to stderr by default; pass ``logger=`` to select a standard Python logger and
104
+ enable DEBUG for per-stage acquisition details. Logs omit request payloads,
105
+ URL paths/query strings, connection credentials and exception messages.
106
+ """
107
+
108
+ def __init__(
109
+ self,
110
+ *,
111
+ limits: Limits | None = None,
112
+ identity: Identity | None = None,
113
+ browser: Any = None,
114
+ proxy: Any = None,
115
+ http_transport: httpx.AsyncBaseTransport | None = None,
116
+ run_id: str | None = None,
117
+ attempt_id: str | None = None,
118
+ required_capabilities: Iterable[str] = (),
119
+ diagnostics_dir: str | Path | None = None,
120
+ network_diagnostics: bool = False,
121
+ session_pool: SessionPool | None = None,
122
+ before_navigation: NavigationHook | None = None,
123
+ after_navigation: NavigationHook | None = None,
124
+ on_error: FailureHook | None = None,
125
+ on_failure: FailureHook | None = None,
126
+ auto_concurrency: AutoConcurrency | None = None,
127
+ adaptive_policy: AdaptivePolicy | None = None,
128
+ logger: logging.Logger | None = None,
129
+ ) -> None:
130
+ if not isinstance(network_diagnostics, bool):
131
+ raise ConfigurationError("network_diagnostics must be a boolean")
132
+ self.context = CrawlContext(limits or Limits(), run_id=run_id, attempt_id=attempt_id)
133
+ self.log = logger if logger is not None else get_logger()
134
+ self._log_requests = 0
135
+ self._log_failed = 0
136
+ self._cleanup_incomplete = False
137
+ self.identity = identity or Identity()
138
+ self.browser_provider = browser
139
+ self.proxy_provider = proxy
140
+ self._transport = http_transport
141
+ self._http: HttpSession | None = None
142
+ self._proxy: Any = None
143
+ self._browser: Any = None
144
+ self._browser_lock = asyncio.Lock()
145
+ self._resource_lock = asyncio.Lock()
146
+ self._active_acquisitions = 0
147
+ self._acquisition_tasks: dict[asyncio.Task[Any], int] = {}
148
+ self._rotating = False
149
+ self._resource_failure: str | None = None
150
+ self._retired_resources: list[Any] = []
151
+ self._close_task: asyncio.Task[None] | None = None
152
+ self._global = (
153
+ auto_concurrency.controller(ceiling=self.context.limits.concurrency)
154
+ if auto_concurrency is not None
155
+ else asyncio.Semaphore(self.context.limits.concurrency)
156
+ )
157
+ self.adaptive_policy = adaptive_policy or AdaptivePolicy()
158
+ self._sites: dict[str, SiteGate] = {}
159
+ self._entered = False
160
+ self._closed = False
161
+ self._crawling = False
162
+ self._queue_state: dict[str, Any] | None = None
163
+ self._last_checkpoint: CrawlCheckpoint | None = None
164
+ self._checkpoint_unavailable: str | None = None
165
+ self._active: set[asyncio.Task[Any]] = set()
166
+ self._required_capabilities = frozenset(required_capabilities)
167
+ self._diagnostics_dir = Path(diagnostics_dir) if diagnostics_dir is not None else None
168
+ self._diagnostic_count = 0
169
+ self._network_diagnostics = network_diagnostics
170
+ self.session_pool = session_pool
171
+ self._owns_session_pool = False
172
+ self.before_navigation = before_navigation
173
+ self.after_navigation = after_navigation
174
+ self.on_error = on_error
175
+ self.on_failure = on_failure
176
+ if session_pool is not None and proxy is not None:
177
+ raise ConfigurationError(
178
+ "Pool proxy configuration belongs in SessionPool.proxy_factory"
179
+ )
180
+ self.failures: list[Failure] = []
181
+
182
+ @property
183
+ def stats(self) -> Stats:
184
+ return self.context.stats
185
+
186
+ def _log(self, level: int, message: str, *args: object) -> None:
187
+ # 日志接收端故障不能改变采集、取消或资源回收的行为。
188
+ try:
189
+ self.log.log(level, "[crawler] " + message, *args)
190
+ except Exception:
191
+ pass
192
+
193
+ def cancel(self, reason: str = "cancelled") -> None:
194
+ self.context.cancel(reason)
195
+
196
+ @property
197
+ def proxy(self) -> Any:
198
+ """Current proxy handle. Explicit rotation affects new upstream connections."""
199
+ self._require_open()
200
+ if self.session_pool is not None:
201
+ raise ConfigurationError("Proxy handles are session-scoped in SessionPool mode")
202
+ return self._proxy
203
+
204
+ async def rotate_proxy(self) -> None:
205
+ """Rotate at an idle boundary and start a fresh acquisition generation.
206
+
207
+ Existing HTTP connections and browser contexts are retired even when the
208
+ provider keeps the same tunnel URL. The new generation is seeded only from
209
+ the configured ``Identity``; cookies observed by the old exit are discarded.
210
+ """
211
+ self._require_open()
212
+ if self.session_pool is not None:
213
+ raise ConfigurationError(
214
+ "rotate_proxy is unavailable in pool mode; retire the current session instead"
215
+ )
216
+ if self._proxy is None:
217
+ raise ConfigurationError("A proxy Provider is required for rotation")
218
+ await self.context.run(self._rotate_proxy_generation())
219
+
220
+ async def retire_sessions(self) -> None:
221
+ """Close all idle pool generations; active sessions retire by lease control."""
222
+ self._require_open()
223
+ if self.session_pool is None:
224
+ raise ConfigurationError("A SessionPool is required to retire sessions")
225
+ await self.context.run(self.session_pool.retire_idle())
226
+
227
+ def _new_http(self) -> HttpSession:
228
+ proxy_url = self._proxy.http_url() if self._proxy is not None else None
229
+ return HttpSession(
230
+ self.context.limits,
231
+ self.identity,
232
+ proxy_url=proxy_url,
233
+ transport=self._transport,
234
+ close_transport=self._transport is None,
235
+ )
236
+
237
+ @asynccontextmanager
238
+ async def _acquisition(self) -> AsyncIterator[None]:
239
+ async with self._resource_lock:
240
+ if self._closed:
241
+ raise RuntimeError("Crawler closed before acquisition could start")
242
+ self.context.check()
243
+ if self._resource_failure is not None:
244
+ raise ConfigurationError(
245
+ "Crawler acquisition resources are unavailable after proxy rotation failure"
246
+ )
247
+ if self._rotating:
248
+ raise ConfigurationError("Proxy rotation is already in progress")
249
+ self._active_acquisitions += 1
250
+ task = asyncio.current_task()
251
+ if task is not None:
252
+ self._acquisition_tasks[task] = self._acquisition_tasks.get(task, 0) + 1
253
+ marker = _AcquisitionScope(id(self))
254
+ token = _ACQUISITION_SCOPES.set((*_ACQUISITION_SCOPES.get(), marker))
255
+ try:
256
+ yield
257
+ finally:
258
+ marker.active = False
259
+ _ACQUISITION_SCOPES.reset(token)
260
+ async with self._resource_lock:
261
+ self._active_acquisitions -= 1
262
+ if task is not None:
263
+ remaining = self._acquisition_tasks[task] - 1
264
+ if remaining:
265
+ self._acquisition_tasks[task] = remaining
266
+ else:
267
+ del self._acquisition_tasks[task]
268
+
269
+ async def _retire(self, resources: Iterable[Any]) -> list[BaseException]:
270
+ errors: list[BaseException] = []
271
+ for resource in resources:
272
+ if resource is None:
273
+ continue
274
+ self._retired_resources.append(resource)
275
+ try:
276
+ await self._close_resource(resource)
277
+ except asyncio.CancelledError as error:
278
+ errors.append(error)
279
+ except Exception as error:
280
+ errors.append(error)
281
+ else:
282
+ self._retired_resources.remove(resource)
283
+ return errors
284
+
285
+ async def _close_resource(self, resource: Any) -> None:
286
+ closer = getattr(resource, "close", None)
287
+ if not callable(closer):
288
+ closer = getattr(resource, "aclose", None)
289
+ if not callable(closer):
290
+ raise TypeError("Acquisition resource exposes no async close method")
291
+ result = closer()
292
+ if inspect.isawaitable(result):
293
+ await asyncio.wait_for(result, timeout=10)
294
+
295
+ async def _rotate_proxy_generation(self) -> None:
296
+ async with self._resource_lock:
297
+ if self._closed:
298
+ raise RuntimeError("Crawler closed before proxy rotation could start")
299
+ self.context.check()
300
+ if self._resource_failure is not None:
301
+ raise ConfigurationError(
302
+ "Crawler acquisition resources are unavailable after proxy rotation failure"
303
+ )
304
+ if self._active_acquisitions or self._crawling:
305
+ raise ConfigurationError(
306
+ "Proxy rotation requires an idle crawler with no active acquisition or crawl"
307
+ )
308
+ self._rotating = True
309
+ old_browser, old_http = self._browser, self._http
310
+ self._browser = None
311
+ self._http = None
312
+ try:
313
+ errors = await self._retire((old_browser, old_http))
314
+ cancellation = next(
315
+ (error for error in errors if isinstance(error, asyncio.CancelledError)), None
316
+ )
317
+ if cancellation is not None:
318
+ self._resource_failure = "rotation_cancelled"
319
+ raise cancellation
320
+ if errors:
321
+ self._resource_failure = "retirement_failed"
322
+ raise FetchError(
323
+ "Proxy rotation resource retirement failed", phase="proxy_rotation"
324
+ ) from None
325
+ try:
326
+ assert self._proxy is not None
327
+ await self._proxy.rotate()
328
+ except asyncio.CancelledError:
329
+ self._resource_failure = "rotation_cancelled"
330
+ raise
331
+ except (ConfigurationError, LimitReached):
332
+ self._resource_failure = "rotation_failed"
333
+ raise
334
+ except Exception:
335
+ self._resource_failure = "rotation_failed"
336
+ raise ConfigurationError(
337
+ "Proxy rotation failed; crawler resources are unavailable"
338
+ ) from None
339
+ try:
340
+ self._http = self._new_http()
341
+ except Exception:
342
+ self._resource_failure = "http_rebuild_failed"
343
+ raise ConfigurationError(
344
+ "HTTP session rebuild failed after proxy rotation"
345
+ ) from None
346
+ finally:
347
+ self._rotating = False
348
+
349
+ async def __aenter__(self) -> Crawler:
350
+ if self._entered or self._closed:
351
+ raise RuntimeError(
352
+ "A Crawler is a single-run context; create a new one for another run"
353
+ )
354
+ self._entered = True
355
+ self.context.start()
356
+ if self.proxy_provider is not None:
357
+ proxy_label = provider_name(self.proxy_provider)
358
+ elif self.session_pool is not None and self.session_pool.proxy_factory is not None:
359
+ proxy_label = "session-pool-policy"
360
+ elif self.browser_provider is not None:
361
+ proxy_label = "browser-provider-policy"
362
+ else:
363
+ proxy_label = "none"
364
+ self._log(
365
+ logging.INFO, "Started browser=%s proxy=%s session_pool=%s",
366
+ provider_name(self.browser_provider),
367
+ proxy_label,
368
+ self.session_pool is not None,
369
+ )
370
+ phase = "startup"
371
+ try:
372
+ if self.session_pool is not None:
373
+ self.session_pool.bind(
374
+ limits=self.context.limits,
375
+ identity=self.identity,
376
+ transport=self._transport,
377
+ browser_provider=self.browser_provider,
378
+ close_resource=self._close_resource,
379
+ )
380
+ self._owns_session_pool = True
381
+ return self
382
+ if self.proxy_provider is not None:
383
+ phase = "proxy_acquire"
384
+ self._log(
385
+ logging.DEBUG, "Proxy acquiring provider=%s",
386
+ provider_name(self.proxy_provider),
387
+ )
388
+ self._proxy = await self.context.run(self.proxy_provider.acquire())
389
+ self._log(
390
+ logging.INFO, "Proxy ready provider=%s", provider_name(self.proxy_provider),
391
+ )
392
+ phase = "startup"
393
+ self._http = self._new_http()
394
+ return self
395
+ except BaseException as error:
396
+ stage, code = failure_details(error, phase)
397
+ self._log(logging.ERROR, "Startup failed phase=%s code=%s", stage, code)
398
+ try:
399
+ await self.close()
400
+ except Exception:
401
+ error.add_note("Acquisition resource cleanup also failed")
402
+ raise
403
+
404
+ async def __aexit__(self, *exc: Any) -> None:
405
+ try:
406
+ await self.close()
407
+ except Exception:
408
+ if exc[1] is None:
409
+ raise
410
+ exc[1].add_note("Acquisition resource cleanup also failed")
411
+
412
+ async def close(self) -> None:
413
+ current = asyncio.current_task()
414
+ if any(
415
+ marker.active and marker.crawler_id == id(self) for marker in _ACQUISITION_SCOPES.get()
416
+ ) or (current is not None and current in self._acquisition_tasks):
417
+ raise ConfigurationError("Cannot close a Crawler from an active acquisition")
418
+ if self._close_task is None:
419
+ # Set the terminal state before scheduling cleanup so an in-progress
420
+ # rotation or acquisition cannot complete a new generation.
421
+ self._closed = True
422
+ self.context.cancelled.set()
423
+ self._close_task = asyncio.create_task(self._close_resources())
424
+ cleanup = self._close_task
425
+ try:
426
+ await asyncio.shield(cleanup)
427
+ except asyncio.CancelledError as cancellation:
428
+ # A caller cancellation must not strand shared acquisition resources.
429
+ try:
430
+ await asyncio.shield(cleanup)
431
+ except Exception:
432
+ cancellation.add_note("Crawler cleanup also failed")
433
+ raise
434
+
435
+ async def _close_resources(self) -> None:
436
+ self._log(logging.DEBUG, "Resource cleanup started")
437
+ async with self._resource_lock:
438
+ active = set(self._active) | set(self._acquisition_tasks)
439
+ for task in active:
440
+ task.cancel()
441
+ await asyncio.gather(*active, return_exceptions=True)
442
+ async with self._resource_lock:
443
+ if self._active_acquisitions:
444
+ raise FetchError("Active acquisitions did not stop during cleanup")
445
+ # Each resource is attempted even when another cleanup operation fails.
446
+ errors: list[BaseException] = []
447
+ resources = [
448
+ self.session_pool if self._owns_session_pool else None,
449
+ self._browser,
450
+ self._http,
451
+ *self._retired_resources,
452
+ self._proxy,
453
+ ]
454
+ if self._transport is not None:
455
+ resources.append(self._transport)
456
+ seen: set[int] = set()
457
+ for resource in resources:
458
+ if resource is not None:
459
+ if id(resource) in seen:
460
+ continue
461
+ seen.add(id(resource))
462
+ try:
463
+ await self._close_resource(resource)
464
+ except asyncio.CancelledError as exc:
465
+ errors.append(exc)
466
+ except Exception as exc:
467
+ errors.append(exc)
468
+ cancellation = next(
469
+ (error for error in errors if isinstance(error, asyncio.CancelledError)), None
470
+ )
471
+ if cancellation is not None:
472
+ self._log(logging.WARNING, "Resource cleanup interrupted code=CRAWL_CANCELLED")
473
+ raise cancellation
474
+ if errors:
475
+ self._log(logging.ERROR, "Resource cleanup failed code=CLEANUP_FAILED")
476
+ raise FetchError("One or more acquisition resources failed to close") from None
477
+ self._log(
478
+ logging.WARNING if self._cleanup_incomplete else logging.INFO,
479
+ "Resource cleanup finished status=%s; "
480
+ "requests=%d captured=%d failed=%d retries=%d elapsed=%.3fs",
481
+ "incomplete" if self._cleanup_incomplete else "complete",
482
+ self._log_requests, self.stats.captured, self._log_failed, self.stats.retries,
483
+ max(0, time.monotonic() - (self.context._started or time.monotonic())),
484
+ )
485
+
486
+ def _require_open(self) -> None:
487
+ if not self._entered or self._closed:
488
+ raise RuntimeError("Use async with Crawler(...) before acquiring data")
489
+ self.context.check()
490
+
491
+ def _gate(self, key: str) -> SiteGate:
492
+ if key not in self._sites:
493
+ if len(self._sites) >= self.context.limits.max_requests:
494
+ raise LimitReached("Site-state memory limit reached")
495
+ self._sites[key] = SiteGate(self.context)
496
+ return self._sites[key]
497
+
498
+ async def _get_browser(self, lease: SessionLease | None = None) -> Any:
499
+ if lease is not None:
500
+ self._log(logging.DEBUG, "Browser session acquiring source=session-pool")
501
+ handle = await lease.browser(required_capabilities=self._required_capabilities)
502
+ self._log(logging.DEBUG, "Browser session ready source=session-pool")
503
+ return handle
504
+ async with self._browser_lock:
505
+ if self._browser is None:
506
+ if self.browser_provider is None:
507
+ raise ConfigurationError("A browser Provider is required for browser mode")
508
+ started = time.monotonic()
509
+ self._log(
510
+ logging.DEBUG, "Browser session acquiring provider=%s",
511
+ provider_name(self.browser_provider),
512
+ )
513
+ try:
514
+ self._browser = await self.context.run(
515
+ self.browser_provider.open(identity=self.identity, proxy=self._proxy)
516
+ )
517
+ except BaseException as error:
518
+ _, code = failure_details(error, "browser_open")
519
+ self._log(
520
+ logging.DEBUG, "Browser session failed phase=browser_open code=%s", code,
521
+ )
522
+ raise
523
+ self._log(
524
+ logging.INFO, "Browser session ready elapsed=%.3fs", time.monotonic() - started,
525
+ )
526
+ self._check_capabilities(self._browser)
527
+ return self._browser
528
+
529
+ def _check_capabilities(self, handle: Any) -> None:
530
+ if self._required_capabilities - handle.capabilities:
531
+ raise ConfigurationError("Browser Provider does not support required capabilities")
532
+
533
+ @asynccontextmanager
534
+ async def browser(self) -> AsyncIterator[Any]:
535
+ """Yield a browser handle whose native Pages live until context exit.
536
+
537
+ The handle is exclusive to this context and is always closed on exit.
538
+ Prefer ``fetch`` when one process callback can finish all page work.
539
+ """
540
+ self._require_open()
541
+ if self.session_pool is not None:
542
+ raise ConfigurationError("Manual browser sessions are unavailable in SessionPool mode")
543
+ if self.browser_provider is None:
544
+ raise ConfigurationError("A browser Provider is required")
545
+ async with self._acquisition():
546
+ self._log(
547
+ logging.DEBUG, "Browser session acquiring provider=%s",
548
+ provider_name(self.browser_provider),
549
+ )
550
+ try:
551
+ handle = await self.context.run(
552
+ self.browser_provider.open(identity=self.identity, proxy=self._proxy)
553
+ )
554
+ except BaseException as error:
555
+ _, code = failure_details(error, "browser_open")
556
+ self._log(logging.ERROR, "Browser session failed phase=browser_open code=%s", code)
557
+ raise
558
+ self._log(logging.INFO, "Browser session ready")
559
+ try:
560
+ self._check_capabilities(handle)
561
+ yield handle
562
+ finally:
563
+ primary_error = sys.exc_info()[1]
564
+ try:
565
+ await asyncio.wait_for(handle.close(), timeout=10)
566
+ except BaseException as error:
567
+ self._cleanup_incomplete = True
568
+ self._log(logging.ERROR, "Browser session cleanup failed code=CLEANUP_FAILED")
569
+ if not isinstance(error, Exception):
570
+ raise
571
+ if primary_error is None:
572
+ raise FetchError("Browser session cleanup failed") from None
573
+ primary_error.add_note("Browser session cleanup also failed")
574
+
575
+ async def run_browser(self, collect: BrowserCollect) -> Any:
576
+ """Run a page-driven flow with one native Page and this CrawlContext.
577
+
578
+ ``collect(page, context)`` owns navigation, extraction and business output.
579
+ Its return value is passed through; nothing is written to a Dataset here.
580
+ The whole flow observes cancellation and ``Limits.max_duration``. It is
581
+ never automatically retried because browser actions may have side effects.
582
+
583
+ Pages created through browser_tools navigation helpers are scoped there;
584
+ the root Page is closed with the manual browser handle on every exit.
585
+ Direct Playwright actions do not increment request/page/result counters
586
+ or apply per-site scheduling; use fetch/crawl for request-driven work.
587
+ Browser tools account for their explicit navigations, not every subresource
588
+ or navigation caused by native Playwright actions. SessionPool is unsupported,
589
+ as with the existing manual ``browser()`` context.
590
+
591
+ Failure logs retain a fixed exception type and the root Page's hostname,
592
+ never exception messages or URL paths. The root Page may differ from a
593
+ failing detail tab. Capture evidence inside that tab's scope, before its
594
+ cleanup, when private diagnostic files are explicitly needed.
595
+ """
596
+ self._require_open()
597
+ if not callable(collect):
598
+ raise ConfigurationError("Browser collect must be callable")
599
+ async with self.browser() as handle:
600
+ try:
601
+ page = await self.context.run(
602
+ handle.new_page(), timeout=self.context.limits.request_timeout,
603
+ )
604
+ except BaseException as error:
605
+ phase, code = failure_details(error, "page_creation")
606
+ self._log(
607
+ logging.ERROR, "Browser flow failed phase=%s code=%s%s",
608
+ phase, code, failure_context(error),
609
+ )
610
+ raise
611
+
612
+ async def invoke() -> Any:
613
+ result = collect(page, self.context)
614
+ return await result if inspect.isawaitable(result) else result
615
+
616
+ started = time.monotonic()
617
+ self._log(logging.DEBUG, "Browser flow started")
618
+ try:
619
+ result = await self.context.run(invoke())
620
+ except BaseException as error:
621
+ phase, code = failure_details(error, "collect")
622
+ self._log(
623
+ logging.ERROR, "Browser flow failed phase=%s code=%s%s root_host=%s",
624
+ phase, code, failure_context(error), root_page_host(page),
625
+ )
626
+ raise
627
+ self._log(
628
+ logging.INFO, "Browser flow finished elapsed=%.3fs", time.monotonic() - started,
629
+ )
630
+ return result
631
+
632
+ async def fetch(self, request: Request | str, *, process: Process | None = None) -> Any:
633
+ """Acquire one request and optionally transform it with ``process``.
634
+
635
+ Without ``process`` this returns :class:`CaptureResult`. With a process
636
+ callback it returns that callback's value. A browser Page is valid only
637
+ during the callback; bound individual Page operations with
638
+ ``context.run(page_method(), timeout=...)`` when needed.
639
+ """
640
+ self._require_open()
641
+ req = Request(request) if isinstance(request, str) else request
642
+ self._log_requests += 1
643
+ request_number = self._log_requests
644
+ started = time.monotonic()
645
+ self._log(
646
+ logging.DEBUG, "Request started request=%d mode=%s host=%s",
647
+ request_number, req.mode, target_host(req.url),
648
+ )
649
+ try:
650
+ async with self._acquisition():
651
+ data = await self.context.run(self._fetch(req, process))
652
+ except BaseException as error:
653
+ self._log_failed += 1
654
+ phase, code = failure_details(error, "request")
655
+ self._log(
656
+ logging.ERROR,
657
+ "Request failed request=%d phase=%s code=%s mode=%s host=%s elapsed=%.3fs%s",
658
+ request_number, phase, code, req.mode, target_host(req.url),
659
+ time.monotonic() - started, failure_context(error),
660
+ )
661
+ raise
662
+ self._log(
663
+ logging.INFO, "Request finished request=%d mode=%s host=%s elapsed=%.3fs",
664
+ request_number, req.mode, target_host(req.url), time.monotonic() - started,
665
+ )
666
+ return data
667
+
668
+ async def _fetch(self, request: Request, process: Process | None) -> Any:
669
+ notified = False
670
+ try:
671
+ if self.session_pool is None:
672
+ return await self._fetch_with_resources(request, process, None)
673
+ if any(
674
+ marker.active and marker.crawler_id == id(self)
675
+ for marker in _SESSION_FETCH_SCOPES.get()
676
+ ):
677
+ raise _RecursivePooledFetch(
678
+ "A pooled fetch cannot recursively await another fetch on the same Crawler"
679
+ )
680
+ target_origin = origin(request.url)
681
+ affinity = (
682
+ ("explicit", f"{target_origin}\0{request.session_key}")
683
+ if request.session_key is not None
684
+ else ("origin", target_origin)
685
+ )
686
+ async with self.session_pool.lease(affinity, origin=target_origin) as lease:
687
+ marker = _SessionFetchScope(id(self))
688
+ scope_token = _SESSION_FETCH_SCOPES.set((*_SESSION_FETCH_SCOPES.get(), marker))
689
+ session_token = self.context._set_session(lease.session)
690
+ try:
691
+ return await self._fetch_with_resources(request, process, lease)
692
+ except (CrawlCancelled, LimitReached):
693
+ raise
694
+ except Exception as exc:
695
+ notified = True
696
+ await self._notify(self.on_failure, self._failure(request, exc))
697
+ raise
698
+ finally:
699
+ marker.active = False
700
+ self.context._reset_session(session_token)
701
+ _SESSION_FETCH_SCOPES.reset(scope_token)
702
+ except (CrawlCancelled, LimitReached):
703
+ raise
704
+ except Exception as exc:
705
+ if not notified:
706
+ await self._notify(self.on_failure, self._failure(request, exc))
707
+ raise
708
+
709
+ async def _fetch_with_resources(
710
+ self, request: Request, process: Process | None, lease: SessionLease | None
711
+ ) -> Any:
712
+ async with self._global:
713
+ if request.mode != "auto":
714
+ return await self._fetch_attempts(request, process, lease)
715
+ initial = self.adaptive_policy.initial_request(request)
716
+ capture = await self._fetch_attempts(initial, None, lease)
717
+ decision = await self.adaptive_policy.decide(capture)
718
+ if decision.use_browser:
719
+ self._log(logging.INFO, "Adaptive acquisition switching HTTP to browser")
720
+ browser_request = self.adaptive_policy.browser_request(request)
721
+
722
+ async def selected(capture: CaptureResult, ctx: CrawlContext) -> Any:
723
+ capture.diagnostics["adaptive"] = {
724
+ "selected": "browser",
725
+ "reason": decision.reason,
726
+ "http_status": capture_status,
727
+ }
728
+ if process is None:
729
+ return capture
730
+ result = process(capture, ctx)
731
+ return await result if inspect.isawaitable(result) else result
732
+
733
+ capture_status = capture.status
734
+ return await self._fetch_attempts(browser_request, selected, lease)
735
+ capture.diagnostics["adaptive"] = {
736
+ "selected": "http",
737
+ "reason": decision.reason,
738
+ }
739
+ return await self._process_capture(capture, process)
740
+
741
+ async def _process_capture(self, capture: CaptureResult, process: Process | None) -> Any:
742
+ if process is None:
743
+ return capture
744
+ started = time.monotonic()
745
+ self._log(logging.DEBUG, "Process started host=%s", target_host(capture.request.url))
746
+ try:
747
+ result = process(capture, self.context)
748
+ result = await result if inspect.isawaitable(result) else result
749
+ self._log(logging.DEBUG, "Process finished elapsed=%.3fs", time.monotonic() - started)
750
+ return result
751
+ except (CrawlCancelled, LimitReached, _RecursivePooledFetch):
752
+ raise
753
+ except Exception as exc:
754
+ code = process_error_code(exc)
755
+ self._log(logging.DEBUG, "Process failed phase=process code=%s", code)
756
+ capture.diagnostics["process_error_code"] = code
757
+ await self._diagnose(capture, code)
758
+ raise ProcessingError(
759
+ "Developer process failed; acquisition was not retried",
760
+ capture=capture,
761
+ process_error_code=code,
762
+ ) from None
763
+
764
+ async def _fetch_attempts(
765
+ self, request: Request, process: Process | None, lease: SessionLease | None
766
+ ) -> Any:
767
+ self.context.check()
768
+ if self.stats.pages >= self.context.limits.max_pages:
769
+ self.stats.stop_reason = "max_pages"
770
+ raise LimitReached("Page limit reached")
771
+ self.stats.pages += 1
772
+ retry_events: list[dict[str, Any]] = []
773
+ for attempt in range(self.context.limits.retries + 1):
774
+ self._log(
775
+ logging.DEBUG, "Acquisition attempt=%d/%d mode=%s host=%s",
776
+ attempt + 1, self.context.limits.retries + 1,
777
+ request.mode, target_host(request.url),
778
+ )
779
+ page = None
780
+ handle = None
781
+ capture = None
782
+ network = None
783
+ cookie_handle = None
784
+ http_used = False
785
+ try:
786
+ if request.mode == "browser":
787
+ handle = await self._get_browser(lease)
788
+ if lease is not None:
789
+ await lease.sync_to_browser(handle)
790
+ cookie_handle = handle
791
+ try:
792
+ page = await handle.new_page()
793
+ except Exception:
794
+ raise FetchError(
795
+ "Browser page creation failed", phase="page_creation"
796
+ ) from None
797
+ async with self._gate(request.site_key or origin(request.url)):
798
+ self.context.reserve_request()
799
+ started = time.monotonic()
800
+ url = str(httpx.URL(request.url).copy_merge_params(request.params or {}))
801
+ phase = "navigation"
802
+ try:
803
+ network = observe_network(
804
+ page,
805
+ started,
806
+ capture_events=self._network_diagnostics,
807
+ )
808
+ try:
809
+ navigation_timeout = (
810
+ self.context.limits.navigation_timeout
811
+ or self.context.limits.request_timeout
812
+ )
813
+ snapshot_timeout = (
814
+ self.context.limits.snapshot_timeout
815
+ or self.context.limits.request_timeout
816
+ )
817
+ async with asyncio.timeout(navigation_timeout):
818
+ phase = "before_navigation"
819
+ await self._navigation_hook(
820
+ "before_navigation",
821
+ self.before_navigation,
822
+ request,
823
+ page,
824
+ )
825
+ phase = "navigation"
826
+ response = await page.goto(
827
+ url,
828
+ wait_until=request.wait_until,
829
+ timeout=navigation_timeout * 1000,
830
+ )
831
+ phase = "after_navigation"
832
+ await self._navigation_hook(
833
+ "after_navigation",
834
+ self.after_navigation,
835
+ request,
836
+ page,
837
+ )
838
+ async with asyncio.timeout(snapshot_timeout):
839
+ phase = "headers"
840
+ headers = await response.all_headers() if response else {}
841
+ phase = "snapshot"
842
+ body = (
843
+ (await page.content()).encode() if request.snapshot else b""
844
+ )
845
+ finally:
846
+ if network is not None:
847
+ network.detach()
848
+ except Exception as exc:
849
+ if isinstance(exc, FetchError):
850
+ raise
851
+ code = browser_error_code(exc)
852
+ main_document_status = (
853
+ network.main_document_status if network is not None else None
854
+ )
855
+ retryable = phase == "navigation" and (
856
+ isinstance(exc, TimeoutError)
857
+ or type(exc).__name__ == "TimeoutError"
858
+ or code
859
+ in {
860
+ "ERR_CONNECTION_RESET",
861
+ "ERR_CONNECTION_CLOSED",
862
+ "ERR_TIMED_OUT",
863
+ }
864
+ or (
865
+ code == "ERR_HTTP_RESPONSE_CODE_FAILURE"
866
+ and main_document_status in _RETRYABLE_STATUSES
867
+ )
868
+ )
869
+ failure = FetchError(
870
+ f"Browser {phase} failed",
871
+ retryable=retryable,
872
+ phase=phase,
873
+ browser_error_code=code,
874
+ network_diagnostics=(
875
+ network.summary()
876
+ if self._network_diagnostics and network is not None
877
+ else None
878
+ ),
879
+ main_document_status=main_document_status,
880
+ )
881
+ timeout_seconds = (
882
+ navigation_timeout
883
+ if phase in {"before_navigation", "navigation", "after_navigation"}
884
+ else snapshot_timeout
885
+ )
886
+ failure.browser_diagnostics = {
887
+ "exception_type": _safe_exception_type(exc),
888
+ "wait_until": request.wait_until,
889
+ "timeout_seconds": timeout_seconds,
890
+ "elapsed_seconds": round(time.monotonic() - started, 3),
891
+ }
892
+ raise failure from None
893
+ if len(body) > self.context.limits.max_response_bytes:
894
+ raise ResponseTooLarge(
895
+ "DOM snapshot exceeds configured byte limit",
896
+ network_diagnostics=(
897
+ network.summary()
898
+ if self._network_diagnostics and network is not None
899
+ else None
900
+ ),
901
+ main_document_status=(
902
+ network.main_document_status if network is not None else None
903
+ ),
904
+ )
905
+ diagnostics: dict[str, Any] = {
906
+ "mode": "browser",
907
+ "wait_until": request.wait_until,
908
+ "snapshot": "included" if request.snapshot else "skipped",
909
+ }
910
+ if self._network_diagnostics and network is not None:
911
+ diagnostics["network"] = network.summary()
912
+ capture = CaptureResult(
913
+ request,
914
+ page.url,
915
+ response.status if response else None,
916
+ headers,
917
+ body,
918
+ page=page,
919
+ elapsed=time.monotonic() - started,
920
+ diagnostics=diagnostics,
921
+ )
922
+ else:
923
+ http = lease.http if lease is not None else self._http
924
+ assert http is not None
925
+ if lease is not None:
926
+ await lease.sync_to_http()
927
+ http_used = True
928
+ capture = await http.fetch(request, self.context, self._gate)
929
+ capture.attempts = attempt + 1
930
+ self._log(
931
+ logging.DEBUG, "Page acquired mode=%s status=%s elapsed=%.3fs",
932
+ request.mode, capture.status, capture.elapsed,
933
+ )
934
+ if retry_events:
935
+ capture.diagnostics["retry_events"] = list(retry_events)
936
+ if (
937
+ capture.status in _RETRYABLE_STATUSES
938
+ and request.can_retry
939
+ and attempt < self.context.limits.retries
940
+ ):
941
+ await self._attempt_status_error(capture, attempt + 1)
942
+ if lease is not None and capture.status in {401, 403, 429}:
943
+ lease.session.mark_bad()
944
+ delay = self._retry_delay(capture.headers.get("retry-after"), attempt)
945
+ response_retry_event: dict[str, Any] = {
946
+ "attempt": attempt + 1,
947
+ "phase": "response",
948
+ "status": capture.status,
949
+ }
950
+ if "network" in capture.diagnostics:
951
+ response_retry_event["network"] = capture.diagnostics["network"]
952
+ retry_events.append(response_retry_event)
953
+ self._gate(request.site_key or origin(capture.final_url)).defer(delay)
954
+ self.stats.retries += 1
955
+ self._log(
956
+ logging.WARNING,
957
+ "Retry scheduled phase=response status=%s attempt=%d/%d delay=%.3fs",
958
+ capture.status, attempt + 1, self.context.limits.retries + 1, delay,
959
+ )
960
+ await self.context.sleep(delay)
961
+ continue
962
+ if capture.status in {401, 403, 429, 500, 502, 503, 504}:
963
+ await self._attempt_status_error(capture, attempt + 1)
964
+ if lease is not None and capture.status in {401, 403, 429}:
965
+ lease.session.mark_bad()
966
+ elif lease is not None and (capture.status is None or capture.status < 400):
967
+ lease.session.mark_good()
968
+ self.stats.captured += 1
969
+ if capture.access_issue:
970
+ self._log(
971
+ logging.WARNING, "Page access issue detected; inspect capture diagnostics",
972
+ )
973
+ await self._diagnose(capture, capture.access_issue)
974
+ return await self._process_capture(capture, process)
975
+ except FetchError as exc:
976
+ if lease is not None and self._damages_session(exc):
977
+ lease.session.mark_bad()
978
+ await self._notify(
979
+ self.on_error,
980
+ self._failure(request, exc, attempt=attempt + 1),
981
+ )
982
+ if (
983
+ not exc.retryable
984
+ or not request.can_retry
985
+ or attempt >= self.context.limits.retries
986
+ ):
987
+ exc.retry_events = list(retry_events)
988
+ raise
989
+ event: dict[str, Any] = {"attempt": attempt + 1, "phase": exc.phase}
990
+ if exc.browser_error_code is not None:
991
+ event["browser_error_code"] = exc.browser_error_code
992
+ if exc.main_document_status is not None:
993
+ event["main_document_status"] = exc.main_document_status
994
+ if exc.network_diagnostics is not None:
995
+ event["network"] = exc.network_diagnostics
996
+ retry_events.append(event)
997
+ self.stats.retries += 1
998
+ delay = self._retry_delay(None, attempt)
999
+ phase, code = failure_details(exc, "request")
1000
+ self._log(
1001
+ logging.WARNING,
1002
+ "Retry scheduled phase=%s code=%s attempt=%d/%d delay=%.3fs",
1003
+ phase, code, attempt + 1, self.context.limits.retries + 1, delay,
1004
+ )
1005
+ await self.context.sleep(delay)
1006
+ finally:
1007
+ primary_error = sys.exc_info()[1]
1008
+
1009
+ async def finish_attempt(
1010
+ cookie_handle: Any = cookie_handle,
1011
+ http_used: bool = http_used,
1012
+ page: Any = page,
1013
+ handle: Any = handle,
1014
+ capture: CaptureResult | None = capture,
1015
+ ) -> None:
1016
+ cleanup_error: BaseException | None = None
1017
+ try:
1018
+ if lease is not None:
1019
+ if cookie_handle is not None:
1020
+ await asyncio.wait_for(lease.sync_from_browser(cookie_handle), 5)
1021
+ elif http_used:
1022
+ await asyncio.wait_for(lease.sync_from_http(), 5)
1023
+ except BaseException as exc:
1024
+ cleanup_error = exc
1025
+ if lease is not None:
1026
+ lease.session.retire()
1027
+ finally:
1028
+ if page is not None:
1029
+ try:
1030
+ await handle.close_page(page)
1031
+ except BaseException as exc:
1032
+ if lease is not None:
1033
+ lease.session.retire()
1034
+ if cleanup_error is None:
1035
+ if isinstance(exc, Exception):
1036
+ cleanup_error = FetchError(
1037
+ "Browser page cleanup failed", phase="page_cleanup"
1038
+ )
1039
+ else:
1040
+ cleanup_error = exc
1041
+ else:
1042
+ cleanup_error.add_note("Browser page cleanup also failed")
1043
+ finally:
1044
+ if capture is not None and capture.page is page:
1045
+ capture.page = None
1046
+ if cleanup_error is not None:
1047
+ raise cleanup_error
1048
+
1049
+ cleanup = asyncio.create_task(finish_attempt())
1050
+ cancellation: asyncio.CancelledError | None = None
1051
+ while not cleanup.done():
1052
+ try:
1053
+ await asyncio.shield(cleanup)
1054
+ except asyncio.CancelledError as exc:
1055
+ cancellation = cancellation or exc
1056
+ except Exception:
1057
+ break
1058
+ try:
1059
+ cleanup.result()
1060
+ except BaseException as cleanup_error:
1061
+ if cancellation is not None:
1062
+ cancellation.add_note("Attempt cleanup also failed")
1063
+ elif isinstance(cleanup_error, asyncio.CancelledError):
1064
+ raise
1065
+ elif primary_error is None:
1066
+ if isinstance(cleanup_error, FetchError):
1067
+ await self._notify(
1068
+ self.on_error,
1069
+ self._failure(request, cleanup_error, attempt=attempt + 1),
1070
+ )
1071
+ raise
1072
+ else:
1073
+ primary_error.add_note("Attempt resource or cookie cleanup also failed")
1074
+ if cancellation is not None:
1075
+ raise cancellation
1076
+ raise FetchError("Acquisition retry limit reached")
1077
+
1078
+ def _damages_session(self, error: FetchError) -> bool:
1079
+ """Only transport/access failures affect reuse health, never hooks or parsing."""
1080
+ return error.phase == "http_transport" or (
1081
+ error.phase == "navigation"
1082
+ and (
1083
+ (error.retryable and error.main_document_status is None)
1084
+ or error.browser_error_code is not None
1085
+ or error.main_document_status in {401, 403, 429}
1086
+ )
1087
+ )
1088
+
1089
+ async def _navigation_hook(
1090
+ self, name: str, hook: NavigationHook | None, request: Request, page: Any
1091
+ ) -> None:
1092
+ if hook is None:
1093
+ return
1094
+ try:
1095
+ result = hook(request, page, self.context)
1096
+ if inspect.isawaitable(result):
1097
+ await result
1098
+ except (CrawlCancelled, LimitReached):
1099
+ raise
1100
+ except Exception:
1101
+ raise FetchError(f"{name} hook failed", phase=name) from None
1102
+
1103
+ async def _attempt_status_error(self, capture: CaptureResult, attempt: int) -> None:
1104
+ await self._notify(
1105
+ self.on_error,
1106
+ Failure(
1107
+ capture.request,
1108
+ "network",
1109
+ "Acquisition attempt returned an error status",
1110
+ {"phase": "response", "status": capture.status, "attempt": attempt},
1111
+ ),
1112
+ )
1113
+
1114
+ async def _notify(self, hook: FailureHook | None, failure: Failure) -> None:
1115
+ if hook is None:
1116
+ return
1117
+ try:
1118
+ result = hook(failure, self.context)
1119
+ if inspect.isawaitable(result):
1120
+ timeout = min(self.context.limits.request_timeout, self.context.remaining)
1121
+ await asyncio.wait_for(result, timeout=max(0.001, timeout))
1122
+ except Exception as exc:
1123
+ failure.diagnostics["hook_error_code"] = (
1124
+ "HOOK_TIMEOUT" if isinstance(exc, TimeoutError) else "HOOK_ERROR"
1125
+ )
1126
+
1127
+ def _failure(self, request: Request, exc: Exception, *, attempt: int | None = None) -> Failure:
1128
+ diagnostics = dict(getattr(getattr(exc, "capture", None), "diagnostics", {}))
1129
+ phase = getattr(exc, "phase", None)
1130
+ if phase is not None:
1131
+ diagnostics["phase"] = phase
1132
+ if attempt is not None:
1133
+ diagnostics["attempt"] = attempt
1134
+ if isinstance(exc, ProcessingError):
1135
+ diagnostics["process_error_code"] = process_error_code(exc)
1136
+ code = browser_error_code(exc)
1137
+ if code is not None:
1138
+ diagnostics["browser_error_code"] = code
1139
+ http_code = http_error_code(exc)
1140
+ if http_code is not None:
1141
+ diagnostics["http_error_code"] = http_code
1142
+ if isinstance(exc, FetchError) and exc.retry_events:
1143
+ diagnostics["retry_events"] = exc.retry_events
1144
+ if isinstance(exc, FetchError) and exc.network_diagnostics is not None:
1145
+ diagnostics["network"] = exc.network_diagnostics
1146
+ if isinstance(exc, FetchError) and exc.main_document_status is not None:
1147
+ diagnostics["main_document_status"] = exc.main_document_status
1148
+ if isinstance(exc, FetchError):
1149
+ diagnostics.update(exc.browser_diagnostics)
1150
+ return Failure(
1151
+ request,
1152
+ getattr(exc, "kind", "browser"),
1153
+ "Acquisition or process failed",
1154
+ diagnostics,
1155
+ )
1156
+
1157
+ async def _diagnose(self, capture: CaptureResult, kind: str) -> None:
1158
+ capture.diagnostics["error_kind"] = kind
1159
+ # Explicit opt-in only: snapshots can contain sensitive website content.
1160
+ if self._diagnostics_dir is None or self._diagnostic_count >= 20:
1161
+ return
1162
+ self._diagnostic_count += 1
1163
+ try:
1164
+ self._diagnostics_dir.mkdir(parents=True, exist_ok=True)
1165
+ stem = self._diagnostics_dir / uuid4().hex
1166
+ snapshot = stem.with_suffix(".html" if capture.page is not None else ".body")
1167
+ snapshot.write_bytes(capture.body[: min(self.context.limits.max_response_bytes, 2**21)])
1168
+ capture.diagnostics["snapshot_path"] = str(snapshot)
1169
+ if capture.page is not None:
1170
+ shot = await asyncio.wait_for(capture.page.screenshot(), timeout=5)
1171
+ if len(shot) <= 2**21:
1172
+ image = stem.with_suffix(".png")
1173
+ image.write_bytes(shot)
1174
+ capture.diagnostics["screenshot_path"] = str(image)
1175
+ except Exception:
1176
+ capture.diagnostics["snapshot_error"] = "diagnostic capture unavailable"
1177
+
1178
+ def _retry_delay(self, header: str | None, attempt: int) -> float:
1179
+ if header:
1180
+ try:
1181
+ return max(0, float(header))
1182
+ except ValueError:
1183
+ try:
1184
+ return max(
1185
+ 0, (parsedate_to_datetime(header) - datetime.now(UTC)).total_seconds()
1186
+ )
1187
+ except (ValueError, TypeError, OverflowError):
1188
+ pass
1189
+ return self.context.limits.backoff * 2**attempt
1190
+
1191
+ def export_checkpoint(self) -> CrawlCheckpoint:
1192
+ """Snapshot queued work; Runtime/developer owns private artifact persistence.
1193
+
1194
+ Unread lazy seeds cannot be serialized. Inflight or partly yielded requests
1195
+ are replayed on restore, so processors and outputs must tolerate duplicates.
1196
+ Identity state, browser state and provider configuration are never exported.
1197
+ """
1198
+ state = self._queue_state
1199
+ if state is not None:
1200
+ if not state["exhausted"]:
1201
+ raise ConfigurationError(
1202
+ "Checkpoint cannot include unread seeds; supply a bounded seed batch"
1203
+ )
1204
+ return CrawlCheckpoint.capture(
1205
+ pending=state["pending"],
1206
+ inflight=[*state["tasks"].values(), *state["delivering"]],
1207
+ dedup_keys=state["keys"],
1208
+ seen_keys=self.context._seen,
1209
+ stats=self.stats,
1210
+ elapsed_seconds=max(
1211
+ 0, time.monotonic() - (self.context._started or time.monotonic())
1212
+ ),
1213
+ )
1214
+ if self._last_checkpoint is not None:
1215
+ return self._last_checkpoint
1216
+ raise ConfigurationError(
1217
+ self._checkpoint_unavailable or "No crawl queue is available to checkpoint"
1218
+ )
1219
+
1220
+ async def crawl(
1221
+ self,
1222
+ seeds: Iterable[Request | str] | None = None,
1223
+ *,
1224
+ process: Process | None = None,
1225
+ checkpoint: CrawlCheckpoint | None = None,
1226
+ ) -> AsyncGenerator[CrawlItem, None]:
1227
+ """Yield processed items from a bounded, deduplicated in-memory queue.
1228
+
1229
+ ``process`` may enqueue follow-up requests through the context. Restoring
1230
+ a checkpoint requires a fresh Crawler, and checkpoint persistence remains
1231
+ the caller's responsibility. Each yielded item's browser Page has already
1232
+ been closed and cleared.
1233
+ """
1234
+ self._require_open()
1235
+ if self._crawling:
1236
+ raise RuntimeError("Only one crawl may own a Crawler queue")
1237
+ if checkpoint is not None:
1238
+ if seeds is not None and (not isinstance(seeds, (list, tuple)) or len(seeds) != 0):
1239
+ raise ConfigurationError("Checkpoint restore requires empty seeds or None")
1240
+ if (
1241
+ any(
1242
+ getattr(self.stats, name)
1243
+ for name in (
1244
+ "requests",
1245
+ "pages",
1246
+ "captured",
1247
+ "results",
1248
+ "retries",
1249
+ "duplicates",
1250
+ "failed",
1251
+ )
1252
+ )
1253
+ or self.context._seen
1254
+ or self._last_checkpoint is not None
1255
+ ):
1256
+ raise ConfigurationError("Restore requires a fresh unused Crawler")
1257
+ queued = checkpoint.requeued_requests
1258
+ if len(queued) > self.context.limits.max_pending:
1259
+ raise ConfigurationError("Checkpoint exceeds this Crawler's pending capacity")
1260
+ self.context.stats = checkpoint.stats
1261
+ self.context._seen = set(checkpoint.seen_keys)
1262
+ self.context._started = time.monotonic() - checkpoint.elapsed_seconds
1263
+ self._crawling = True
1264
+ pending: deque[Request] = deque(checkpoint.requeued_requests if checkpoint else ())
1265
+ keys: set[str] = set(checkpoint.dedup_keys if checkpoint else ())
1266
+ iterator = iter(seeds if seeds is not None else ())
1267
+ exhausted = checkpoint is not None or seeds is None
1268
+
1269
+ def enqueue(req: Request) -> bool:
1270
+ self.context.check()
1271
+ if req.deduplicate and req.unique_key in keys:
1272
+ self.stats.duplicates += 1
1273
+ return False
1274
+ if len(pending) >= self.context.limits.max_pending:
1275
+ raise QueueFull("Pending request limit reached; finish a batch before expanding")
1276
+ if len(keys) >= self.context.limits.max_pages:
1277
+ raise LimitReached("Queued-page limit reached")
1278
+ if req.deduplicate:
1279
+ keys.add(req.unique_key)
1280
+ pending.append(req)
1281
+ return True
1282
+
1283
+ async def execute(req: Request) -> tuple[CaptureResult, Any]:
1284
+ holder: list[CaptureResult] = []
1285
+
1286
+ async def callback(capture: CaptureResult, ctx: CrawlContext) -> Any:
1287
+ holder.append(capture)
1288
+ if process is None:
1289
+ return capture
1290
+ result = process(capture, ctx)
1291
+ return await result if inspect.isawaitable(result) else result
1292
+
1293
+ data = await self.fetch(req, process=callback)
1294
+ return holder[0], data
1295
+
1296
+ self.context._enqueue = enqueue
1297
+ tasks: dict[asyncio.Task[Any], Request] = {}
1298
+ delivering: list[Request] = []
1299
+ self._queue_state = {
1300
+ "pending": pending,
1301
+ "keys": keys,
1302
+ "tasks": tasks,
1303
+ "delivering": delivering,
1304
+ "exhausted": exhausted,
1305
+ }
1306
+ active_sites: dict[str, int] = {}
1307
+ try:
1308
+ while True:
1309
+ self.context.check()
1310
+ if self.stats.results >= self.context.limits.max_results:
1311
+ self.stats.stop_reason = "max_results"
1312
+ break
1313
+ while not exhausted and len(pending) < self.context.limits.max_pending:
1314
+ if len(keys) >= self.context.limits.max_pages:
1315
+ exhausted = True
1316
+ self.stats.stop_reason = "max_pages"
1317
+ break
1318
+ try:
1319
+ seed = next(iterator)
1320
+ except StopIteration:
1321
+ exhausted = True
1322
+ self._queue_state["exhausted"] = True
1323
+ break
1324
+ enqueue(Request(seed) if isinstance(seed, str) else seed)
1325
+ candidates = len(pending)
1326
+ while pending and len(tasks) < self.context.limits.concurrency and candidates:
1327
+ candidates -= 1
1328
+ req = pending.popleft()
1329
+ site = req.site_key or origin(req.url)
1330
+ if active_sites.get(site, 0) >= self.context.limits.per_site_concurrency:
1331
+ pending.append(req)
1332
+ continue
1333
+ task = asyncio.create_task(execute(req))
1334
+ tasks[task] = req
1335
+ active_sites[site] = active_sites.get(site, 0) + 1
1336
+ self._active.add(task)
1337
+ if not tasks:
1338
+ break
1339
+ done, _ = await self.context.run(
1340
+ asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
1341
+ )
1342
+ for task in done:
1343
+ if self.stats.results >= self.context.limits.max_results:
1344
+ break
1345
+ req = tasks.pop(task)
1346
+ site = req.site_key or origin(req.url)
1347
+ active_sites[site] -= 1
1348
+ self._active.discard(task)
1349
+ try:
1350
+ capture, data = task.result()
1351
+ except (CrawlCancelled, LimitReached, asyncio.CancelledError):
1352
+ # Keep interrupted work inflight so checkpoint export
1353
+ # can validate replayability instead of silently losing it.
1354
+ tasks[task] = req
1355
+ raise
1356
+ except Exception as exc:
1357
+ self.stats.failed += 1
1358
+ self.failures.append(self._failure(req, exc))
1359
+ continue
1360
+ records = data if isinstance(data, (list, tuple)) else [data]
1361
+ delivering[:] = [req]
1362
+ for record in records:
1363
+ if record is None:
1364
+ continue
1365
+ if self.stats.results >= self.context.limits.max_results:
1366
+ break
1367
+ self.stats.results += 1
1368
+ yield CrawlItem(record, capture)
1369
+ else:
1370
+ delivering.clear()
1371
+ if delivering:
1372
+ break
1373
+ except CrawlCancelled:
1374
+ pass
1375
+ except LimitReached:
1376
+ self.stats.stop_reason = self.stats.stop_reason or "limit"
1377
+ finally:
1378
+ try:
1379
+ self._last_checkpoint = self.export_checkpoint()
1380
+ self._checkpoint_unavailable = None
1381
+ except ConfigurationError:
1382
+ self._last_checkpoint = None
1383
+ self._checkpoint_unavailable = (
1384
+ "Queue snapshot unavailable: unread seeds or non-replayable inflight work"
1385
+ )
1386
+ self.context._enqueue = None
1387
+ for task in tasks:
1388
+ task.cancel()
1389
+ await asyncio.gather(*tasks, return_exceptions=True)
1390
+ self._active.difference_update(tasks)
1391
+ self._queue_state = None
1392
+ self._crawling = False