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,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
|