apify 4.0.2b2__tar.gz → 4.0.2b4__tar.gz
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.
- {apify-4.0.2b2 → apify-4.0.2b4}/CHANGELOG.md +2 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/PKG-INFO +1 -1
- {apify-4.0.2b2 → apify-4.0.2b4}/pyproject.toml +1 -1
- {apify-4.0.2b2 → apify-4.0.2b4}/pyproject.toml.orig +1 -1
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/_async_thread.py +64 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/requests.py +56 -20
- apify-4.0.2b4/src/apify/scrapy/scheduler.py +416 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_request_queue_single_client.py +8 -2
- apify-4.0.2b2/src/apify/scrapy/scheduler.py +0 -203
- {apify-4.0.2b2 → apify-4.0.2b4}/CONTRIBUTING.md +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/LICENSE +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/README.md +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/__init__.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/_actor.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/_charging.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/_configuration.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/_consts.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/_crypto.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/_proxy_configuration.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/_utils.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/_webhook.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/errors.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/events/__init__.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/events/_apify_event_manager.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/events/_types.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/events/py.typed +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/log.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/py.typed +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/request_loaders/__init__.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/request_loaders/_apify_request_list.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/request_loaders/py.typed +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/__init__.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/_actor_runner.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/_detection.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/_logging_config.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/_serialization.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/extensions/__init__.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/extensions/_httpcache.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/middlewares/__init__.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/middlewares/apify_proxy.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/middlewares/py.typed +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/pipelines/__init__.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/pipelines/actor_dataset_push.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/pipelines/py.typed +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/py.typed +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/scrapy/utils.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/__init__.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/__init__.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_alias_resolving.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_api_client_creation.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_dataset_client.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_key_value_store_client.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_models.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_request_queue_client.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_request_queue_shared_client.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_storage_client.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_utils.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/py.typed +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_file_system/__init__.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_file_system/_dataset_client.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_file_system/_key_value_store_client.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_file_system/_storage_client.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_ppe_dataset_mixin.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_smart_apify/__init__.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_smart_apify/_storage_client.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/py.typed +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storages/__init__.py +0 -0
- {apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storages/py.typed +0 -0
|
@@ -9,6 +9,8 @@ All notable changes to this project will be documented in this file.
|
|
|
9
9
|
|
|
10
10
|
- Prolong and track shared request queue locks to prevent duplicate processing ([#1062](https://github.com/apify/apify-sdk-python/pull/1062)) ([ccaad7b](https://github.com/apify/apify-sdk-python/commit/ccaad7b71cd6cd1ddc2c71e5e9d9780001f2688a)) by [@vdusek](https://github.com/vdusek)
|
|
11
11
|
- Verify shared request queue is_finished against per-request reads ([#1087](https://github.com/apify/apify-sdk-python/pull/1087)) ([22613aa](https://github.com/apify/apify-sdk-python/commit/22613aac0ce4e6ef852d5d3daf8c1a25cb3731e3)) by [@vdusek](https://github.com/vdusek)
|
|
12
|
+
- Keep non-forefront reclaimed requests pending in the single request queue client ([#1060](https://github.com/apify/apify-sdk-python/pull/1060)) ([b61890c](https://github.com/apify/apify-sdk-python/commit/b61890cf286c3a83faedac69315acff74b5fba5b)) by [@vdusek](https://github.com/vdusek)
|
|
13
|
+
- **scrapy:** Stop silently dropping in-flight requests and redirects ([#1097](https://github.com/apify/apify-sdk-python/pull/1097)) ([5a0a752](https://github.com/apify/apify-sdk-python/commit/5a0a752523b95c3143e5058fe07785e69817f4a2)) by [@vdusek](https://github.com/vdusek)
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
<!-- git-cliff-unreleased-end -->
|
|
@@ -12,6 +12,9 @@ if TYPE_CHECKING:
|
|
|
12
12
|
|
|
13
13
|
logger = getLogger(__name__)
|
|
14
14
|
|
|
15
|
+
SUBMITTED_PRUNE_THRESHOLD = 128
|
|
16
|
+
"""How many `submit_coro` futures may pile up before the finished ones are dropped from the tracking list."""
|
|
17
|
+
|
|
15
18
|
|
|
16
19
|
class AsyncThread:
|
|
17
20
|
"""Run an asyncio event loop in a dedicated background thread.
|
|
@@ -26,6 +29,9 @@ class AsyncThread:
|
|
|
26
29
|
self._default_timeout = default_timeout
|
|
27
30
|
self._eventloop = asyncio.new_event_loop()
|
|
28
31
|
|
|
32
|
+
self._submitted: list[futures.Future] = []
|
|
33
|
+
"""Futures of the coroutines submitted via `submit_coro` that may still be running."""
|
|
34
|
+
|
|
29
35
|
# Start the event loop in a dedicated daemon thread.
|
|
30
36
|
self._thread = threading.Thread(
|
|
31
37
|
target=self._start_event_loop,
|
|
@@ -74,6 +80,55 @@ class AsyncThread:
|
|
|
74
80
|
future.cancel()
|
|
75
81
|
raise
|
|
76
82
|
|
|
83
|
+
def submit_coro(self, coro: Coroutine) -> futures.Future:
|
|
84
|
+
"""Schedule a coroutine on the event loop without waiting for its result.
|
|
85
|
+
|
|
86
|
+
Use this for work nothing depends on, so the calling thread is not blocked by the round trip. Failures
|
|
87
|
+
are logged, as there is no caller to propagate them to, and `close` cancels whatever is still pending -
|
|
88
|
+
call `wait_for_submitted` first if that matters.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
coro: The coroutine to run.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The future of the scheduled coroutine, for callers that want to inspect its outcome later.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
RuntimeError: If the event loop has been closed.
|
|
98
|
+
"""
|
|
99
|
+
if self._eventloop.is_closed():
|
|
100
|
+
raise RuntimeError(f'The coroutine {coro} cannot be executed because the event loop is closed.')
|
|
101
|
+
|
|
102
|
+
# Callers may go a long time between `wait_for_submitted` calls, so without pruning the list would hold
|
|
103
|
+
# every coroutine ever submitted, with its result.
|
|
104
|
+
if len(self._submitted) >= SUBMITTED_PRUNE_THRESHOLD:
|
|
105
|
+
self._submitted = [submitted for submitted in self._submitted if not submitted.done()]
|
|
106
|
+
|
|
107
|
+
future = asyncio.run_coroutine_threadsafe(coro, self._eventloop)
|
|
108
|
+
future.add_done_callback(self._log_failure)
|
|
109
|
+
self._submitted.append(future)
|
|
110
|
+
|
|
111
|
+
return future
|
|
112
|
+
|
|
113
|
+
def wait_for_submitted(self, timeout: timedelta | None = None) -> None:
|
|
114
|
+
"""Block until the coroutines submitted via `submit_coro` have finished.
|
|
115
|
+
|
|
116
|
+
Use this before anything that would observe their effects, or before `close`, which cancels whatever is
|
|
117
|
+
still running. Coroutines that do not finish within the timeout stay tracked for the next call.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
timeout: The maximum time to wait for the submitted coroutines. Pass `None` to use the
|
|
121
|
+
`default_timeout` passed to the constructor.
|
|
122
|
+
"""
|
|
123
|
+
if timeout is None:
|
|
124
|
+
timeout = self._default_timeout
|
|
125
|
+
|
|
126
|
+
self._submitted = list(futures.wait(self._submitted, timeout=timeout.total_seconds()).not_done)
|
|
127
|
+
|
|
128
|
+
# Callers rely on the effects having landed, so a timeout has to be visible.
|
|
129
|
+
if self._submitted:
|
|
130
|
+
logger.warning(f'{len(self._submitted)} submitted coroutines did not finish within the timeout.')
|
|
131
|
+
|
|
77
132
|
def close(self, timeout: timedelta | None = None) -> None:
|
|
78
133
|
"""Close the event loop and its thread gracefully.
|
|
79
134
|
|
|
@@ -110,6 +165,15 @@ class AsyncThread:
|
|
|
110
165
|
logger.warning('Event loop thread did not exit cleanly! Forcing shutdown...')
|
|
111
166
|
self._force_exit_event_loop()
|
|
112
167
|
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _log_failure(future: futures.Future) -> None:
|
|
170
|
+
"""Log the failure of a coroutine submitted via `submit_coro`."""
|
|
171
|
+
if future.cancelled():
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
if (exc := future.exception()) is not None:
|
|
175
|
+
logger.error('A coroutine submitted to the event loop failed.', exc_info=exc)
|
|
176
|
+
|
|
113
177
|
def _start_event_loop(self) -> None:
|
|
114
178
|
"""Set up and run the asyncio event loop in the dedicated thread."""
|
|
115
179
|
asyncio.set_event_loop(self._eventloop)
|
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from copy import deepcopy
|
|
4
4
|
from logging import getLogger
|
|
5
|
-
from typing import Any,
|
|
5
|
+
from typing import TYPE_CHECKING, Any, get_args
|
|
6
6
|
|
|
7
7
|
from scrapy import Request as ScrapyRequest
|
|
8
8
|
from scrapy import Spider
|
|
@@ -11,11 +11,15 @@ from scrapy.utils.misc import load_object
|
|
|
11
11
|
from scrapy.utils.request import request_from_dict
|
|
12
12
|
|
|
13
13
|
from crawlee._request import UserData
|
|
14
|
-
from crawlee._types import HttpHeaders
|
|
14
|
+
from crawlee._types import HttpHeaders, HttpMethod
|
|
15
|
+
from crawlee._utils.requests import compute_unique_key
|
|
15
16
|
|
|
16
17
|
from ._serialization import decode_from_json, encode_to_json
|
|
17
18
|
from apify import Request as ApifyRequest
|
|
18
19
|
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from typing_extensions import TypeIs
|
|
22
|
+
|
|
19
23
|
logger = getLogger(__name__)
|
|
20
24
|
|
|
21
25
|
|
|
@@ -47,6 +51,38 @@ def _ensure_known_request_class(request_dict: dict[str, Any]) -> None:
|
|
|
47
51
|
)
|
|
48
52
|
|
|
49
53
|
|
|
54
|
+
def _is_http_method(method: str) -> TypeIs[HttpMethod]:
|
|
55
|
+
"""Tell whether `method` is one of the HTTP methods the RQ accepts; Scrapy itself takes any string."""
|
|
56
|
+
return method in get_args(HttpMethod)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _compute_fingerprint(scrapy_request: ScrapyRequest) -> str:
|
|
60
|
+
"""Identify the request an RQ unique key was minted for.
|
|
61
|
+
|
|
62
|
+
`to_scrapy_request` stamps this beside the unique key so `to_apify_request` can tell a request that came out
|
|
63
|
+
of the RQ from one Scrapy derived from it. It covers the URL, the method and the body, so a redirect, a
|
|
64
|
+
method switch and a changed body all read as a different request rather than the parent.
|
|
65
|
+
|
|
66
|
+
Headers are left out even though the unique key covers them: `DefaultHeadersMiddleware` and
|
|
67
|
+
`UserAgentMiddleware` call `headers.setdefault()` on the request in place before the scheduler sees it
|
|
68
|
+
again, so an otherwise untouched request would no longer match its own stamp.
|
|
69
|
+
"""
|
|
70
|
+
method = scrapy_request.method
|
|
71
|
+
if not _is_http_method(method):
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f'Unsupported HTTP method {method!r} in {scrapy_request}; '
|
|
74
|
+
f'the request queue accepts {", ".join(get_args(HttpMethod))}.'
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return compute_unique_key(
|
|
78
|
+
url=scrapy_request.url,
|
|
79
|
+
method=method,
|
|
80
|
+
payload=scrapy_request.body,
|
|
81
|
+
keep_url_fragment=False,
|
|
82
|
+
use_extended_unique_key=True,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
50
86
|
def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequest | None:
|
|
51
87
|
"""Convert a Scrapy request to an Apify request.
|
|
52
88
|
|
|
@@ -76,8 +112,14 @@ def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequ
|
|
|
76
112
|
try:
|
|
77
113
|
if scrapy_request.dont_filter:
|
|
78
114
|
request_kwargs['always_enqueue'] = True
|
|
79
|
-
elif scrapy_request.meta.get('apify_request_unique_key'):
|
|
80
|
-
|
|
115
|
+
elif unique_key := scrapy_request.meta.get('apify_request_unique_key'):
|
|
116
|
+
# Reuse the RQ unique key only while this is still the request it was minted for. Redirects
|
|
117
|
+
# (`Request.replace()`) and spiders forwarding `meta` to another URL inherit the stamp, and reusing
|
|
118
|
+
# it there deduplicates the derived request against its parent. A stamp without a fingerprint was
|
|
119
|
+
# set by hand, so it is taken at face value.
|
|
120
|
+
fingerprint = _compute_fingerprint(scrapy_request)
|
|
121
|
+
if scrapy_request.meta.get('apify_request_fingerprint', fingerprint) == fingerprint:
|
|
122
|
+
request_kwargs['unique_key'] = unique_key
|
|
81
123
|
|
|
82
124
|
# Serialize the Scrapy request now, before `Request.from_url()` runs below. `from_url()` mutates the
|
|
83
125
|
# `user_data` dict it receives in place (it injects a live `CrawleeRequestData` under `__crawlee`), and that
|
|
@@ -113,8 +155,9 @@ def to_apify_request(scrapy_request: ScrapyRequest, spider: Spider) -> ApifyRequ
|
|
|
113
155
|
# the old behavior, which dropped such requests entirely.
|
|
114
156
|
if isinstance(scrapy_request.headers, Headers):
|
|
115
157
|
try:
|
|
116
|
-
|
|
117
|
-
|
|
158
|
+
# `to_unicode_dict()` yields str keys and values, but Scrapy types it as `UserDict[str | bytes, Any]`,
|
|
159
|
+
# so the mapping goes through the model's validator instead of a cast.
|
|
160
|
+
request_kwargs['headers'] = HttpHeaders.model_validate(scrapy_request.headers.to_unicode_dict())
|
|
118
161
|
except UnicodeDecodeError:
|
|
119
162
|
logger.warning(
|
|
120
163
|
'Could not represent Scrapy request headers as Apify request headers (non-UTF-8 values); '
|
|
@@ -165,7 +208,7 @@ def to_scrapy_request(apify_request: ApifyRequest, spider: Spider) -> ScrapyRequ
|
|
|
165
208
|
Returns:
|
|
166
209
|
The converted Scrapy request.
|
|
167
210
|
"""
|
|
168
|
-
if not isinstance(
|
|
211
|
+
if not isinstance(apify_request, ApifyRequest):
|
|
169
212
|
raise TypeError('apify_request must be an apify.Request instance')
|
|
170
213
|
|
|
171
214
|
# If the apify_request comes from the Scrapy
|
|
@@ -187,21 +230,14 @@ def to_scrapy_request(apify_request: ApifyRequest, spider: Spider) -> ScrapyRequ
|
|
|
187
230
|
if not isinstance(scrapy_request, ScrapyRequest):
|
|
188
231
|
raise TypeError('scrapy_request must be an instance of the ScrapyRequest class')
|
|
189
232
|
|
|
190
|
-
# Update the meta field with the meta field from the apify_request
|
|
191
|
-
meta = scrapy_request.meta or {}
|
|
192
|
-
meta.update({'apify_request_unique_key': apify_request.unique_key})
|
|
193
|
-
# scrapy_request.meta is a property, so we have to set it like this
|
|
194
|
-
scrapy_request._meta = meta # noqa: SLF001
|
|
195
|
-
|
|
196
233
|
# If the apify_request comes directly from the Scrapy, typically start URLs.
|
|
197
234
|
else:
|
|
198
|
-
scrapy_request = ScrapyRequest(
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
)
|
|
235
|
+
scrapy_request = ScrapyRequest(url=apify_request.url, method=apify_request.method)
|
|
236
|
+
|
|
237
|
+
# Stamp the unique key with a fingerprint of the request it belongs to, so `to_apify_request` can tell
|
|
238
|
+
# this request apart from the ones Scrapy derives from it.
|
|
239
|
+
scrapy_request.meta['apify_request_unique_key'] = apify_request.unique_key
|
|
240
|
+
scrapy_request.meta['apify_request_fingerprint'] = _compute_fingerprint(scrapy_request)
|
|
205
241
|
|
|
206
242
|
# Add optional 'headers' field
|
|
207
243
|
if apify_request.headers:
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from logging import getLogger
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from scrapy import Spider
|
|
9
|
+
from scrapy import __version__ as scrapy_version
|
|
10
|
+
from scrapy.core.scheduler import BaseScheduler
|
|
11
|
+
from scrapy.utils.reactor import is_asyncio_reactor_installed
|
|
12
|
+
|
|
13
|
+
from ._async_thread import AsyncThread
|
|
14
|
+
from .requests import to_apify_request, to_scrapy_request
|
|
15
|
+
from apify import Configuration
|
|
16
|
+
from apify.storage_clients import ApifyStorageClient
|
|
17
|
+
from apify.storages import RequestQueue
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Coroutine, Iterable
|
|
21
|
+
from concurrent.futures import Future
|
|
22
|
+
|
|
23
|
+
from scrapy.crawler import Crawler
|
|
24
|
+
from scrapy.http.request import Request
|
|
25
|
+
from twisted.internet.defer import Deferred
|
|
26
|
+
|
|
27
|
+
from apify import Request as ApifyRequest
|
|
28
|
+
|
|
29
|
+
InFlightRequest = tuple[ApifyRequest, Request]
|
|
30
|
+
"""A request handed over to Scrapy, paired with the RQ request it came from."""
|
|
31
|
+
|
|
32
|
+
logger = getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def _gather_failures(operations: Iterable[Coroutine[Any, Any, Any]]) -> list[BaseException | None]:
|
|
36
|
+
"""Run RQ updates concurrently, reporting each one's failure, or `None`, in the order given.
|
|
37
|
+
|
|
38
|
+
The whole batch costs one round trip, and one failing update neither raises nor stops the others.
|
|
39
|
+
"""
|
|
40
|
+
outcomes = await asyncio.gather(*operations, return_exceptions=True)
|
|
41
|
+
return [outcome if isinstance(outcome, BaseException) else None for outcome in outcomes]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ApifyScheduler(BaseScheduler):
|
|
45
|
+
"""A Scrapy scheduler that uses the Apify `RequestQueue` to manage requests.
|
|
46
|
+
|
|
47
|
+
This scheduler requires the asyncio Twisted reactor to be installed.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
async_thread_timeout: timedelta = timedelta(seconds=60),
|
|
53
|
+
crawler: Crawler | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
if not is_asyncio_reactor_installed():
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f'{ApifyScheduler.__qualname__} requires the asyncio Twisted reactor. '
|
|
58
|
+
'Make sure you have it configured in the TWISTED_REACTOR setting. See the asyncio '
|
|
59
|
+
'documentation of Scrapy for more information.',
|
|
60
|
+
)
|
|
61
|
+
self._rq: RequestQueue | None = None
|
|
62
|
+
self.spider: Spider | None = None
|
|
63
|
+
self._crawler = crawler
|
|
64
|
+
|
|
65
|
+
self._requests_in_flight: list[InFlightRequest] = []
|
|
66
|
+
"""Requests handed over to Scrapy and not resolved in the RQ yet."""
|
|
67
|
+
|
|
68
|
+
self._pending_marks: list[tuple[list[InFlightRequest], Future]] = []
|
|
69
|
+
"""Batches of mark-as-handled updates dispatched off the hot path, whose outcome is not known yet."""
|
|
70
|
+
|
|
71
|
+
# A thread with the asyncio event loop to run coroutines on.
|
|
72
|
+
self._async_thread = AsyncThread(default_timeout=async_thread_timeout)
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_crawler(cls, crawler: Crawler) -> ApifyScheduler:
|
|
76
|
+
"""Create the scheduler, reading the async-thread timeout from the Scrapy settings.
|
|
77
|
+
|
|
78
|
+
The `APIFY_ASYNC_THREAD_TIMEOUT_SECS` setting (in seconds) caps how long each coroutine run on the
|
|
79
|
+
background event loop may take before timing out; it defaults to 60 seconds.
|
|
80
|
+
"""
|
|
81
|
+
timeout_secs = crawler.settings.getint('APIFY_ASYNC_THREAD_TIMEOUT_SECS', 60)
|
|
82
|
+
return cls(async_thread_timeout=timedelta(seconds=timeout_secs), crawler=crawler)
|
|
83
|
+
|
|
84
|
+
def open(self, spider: Spider) -> Deferred[None] | None:
|
|
85
|
+
"""Open the scheduler.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
spider: The spider that the scheduler is associated with.
|
|
89
|
+
"""
|
|
90
|
+
self.spider = spider
|
|
91
|
+
|
|
92
|
+
if self._crawler is None:
|
|
93
|
+
logger.warning(
|
|
94
|
+
f'{ApifyScheduler.__qualname__} was built without a crawler, so it cannot see what Scrapy is '
|
|
95
|
+
'still working on. Every request is marked as handled the moment it is handed over, and an '
|
|
96
|
+
'interrupted run loses whatever was in flight. Build it with `from_crawler` to avoid that.'
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
self._verify_engine_internals()
|
|
100
|
+
|
|
101
|
+
async def open_rq() -> RequestQueue:
|
|
102
|
+
configuration = Configuration.get_global_configuration()
|
|
103
|
+
if configuration.is_at_home:
|
|
104
|
+
storage_client = ApifyStorageClient()
|
|
105
|
+
return await RequestQueue.open(
|
|
106
|
+
configuration=configuration,
|
|
107
|
+
storage_client=storage_client,
|
|
108
|
+
)
|
|
109
|
+
return await RequestQueue.open()
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
self._rq = self._async_thread.run_coro(open_rq())
|
|
113
|
+
except Exception:
|
|
114
|
+
logger.exception('Failed to open the request queue.')
|
|
115
|
+
# Close the freshly started async thread so a failed open does not leak its event-loop thread.
|
|
116
|
+
# Guard the close so a secondary failure here cannot mask the original error.
|
|
117
|
+
try:
|
|
118
|
+
self._async_thread.close()
|
|
119
|
+
except Exception:
|
|
120
|
+
logger.exception('Failed to close the async thread after a failed scheduler open.')
|
|
121
|
+
raise
|
|
122
|
+
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
def close(self, reason: str) -> None:
|
|
126
|
+
"""Close the scheduler.
|
|
127
|
+
|
|
128
|
+
Resolve the requests Scrapy still holds, then shut down the event loop and its thread gracefully.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
reason: The reason for closing the spider.
|
|
132
|
+
"""
|
|
133
|
+
logger.debug(f'Closing {self.__class__.__name__} due to {reason}...')
|
|
134
|
+
|
|
135
|
+
rq = self._rq
|
|
136
|
+
if isinstance(rq, RequestQueue):
|
|
137
|
+
try:
|
|
138
|
+
self._resolve_finished_requests(wait=True)
|
|
139
|
+
except Exception:
|
|
140
|
+
logger.exception('Failed to resolve the requests still in flight in the request queue.')
|
|
141
|
+
|
|
142
|
+
# Scrapy drains its in-progress requests before closing the scheduler, so this only runs if a future
|
|
143
|
+
# Scrapy closes it earlier; then they go back to the RQ as pending, in a single round trip. One failed
|
|
144
|
+
# reclaim must not strand the rest either.
|
|
145
|
+
if self._requests_in_flight:
|
|
146
|
+
reclaims = _gather_failures(
|
|
147
|
+
rq.reclaim_request(apify_request) for apify_request, _ in self._requests_in_flight
|
|
148
|
+
)
|
|
149
|
+
try:
|
|
150
|
+
outcomes = self._async_thread.run_coro(reclaims)
|
|
151
|
+
except Exception:
|
|
152
|
+
logger.exception('Failed to reclaim the requests still in flight in the request queue.')
|
|
153
|
+
else:
|
|
154
|
+
for (apify_request, _), outcome in zip(self._requests_in_flight, outcomes, strict=True):
|
|
155
|
+
if outcome is not None:
|
|
156
|
+
logger.error(
|
|
157
|
+
f'Failed to reclaim the request {apify_request} in the request queue.',
|
|
158
|
+
exc_info=outcome,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
self._requests_in_flight.clear()
|
|
162
|
+
|
|
163
|
+
# Closing the event loop would silently cancel the updates fired off on the hot path.
|
|
164
|
+
self._async_thread.wait_for_submitted()
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
self._async_thread.close()
|
|
168
|
+
|
|
169
|
+
except KeyboardInterrupt:
|
|
170
|
+
logger.warning('Shutdown interrupted by KeyboardInterrupt!')
|
|
171
|
+
|
|
172
|
+
except Exception:
|
|
173
|
+
logger.exception('Exception occurred while shutting down.')
|
|
174
|
+
|
|
175
|
+
finally:
|
|
176
|
+
logger.debug(f'{self.__class__.__name__} closed successfully.')
|
|
177
|
+
|
|
178
|
+
def has_pending_requests(self) -> bool:
|
|
179
|
+
"""Check if the scheduler has any pending requests.
|
|
180
|
+
|
|
181
|
+
Resolves the requests Scrapy has finished with first, as their outcome is what decides the answer.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
True if the scheduler has any pending requests, False otherwise.
|
|
185
|
+
"""
|
|
186
|
+
if not isinstance(self._rq, RequestQueue):
|
|
187
|
+
raise TypeError('self._rq must be an instance of the RequestQueue class')
|
|
188
|
+
|
|
189
|
+
# Scrapy asks this only once both its downloader and its scraper are idle, so everything still tracked
|
|
190
|
+
# as in flight is provably finished.
|
|
191
|
+
self._resolve_finished_requests(wait=True)
|
|
192
|
+
|
|
193
|
+
# The RQ answers from its own bookkeeping, which a pending update has not reached yet.
|
|
194
|
+
self._async_thread.wait_for_submitted()
|
|
195
|
+
|
|
196
|
+
# Log here before re-raising: this coroutine ran on a separate event-loop thread, and the failure is
|
|
197
|
+
# otherwise easy to lose as it crosses that thread boundary back into Scrapy's synchronous machinery.
|
|
198
|
+
try:
|
|
199
|
+
is_finished = self._async_thread.run_coro(self._rq.is_finished())
|
|
200
|
+
except Exception:
|
|
201
|
+
logger.exception('Failed to check whether the request queue is finished.')
|
|
202
|
+
raise
|
|
203
|
+
|
|
204
|
+
return not is_finished
|
|
205
|
+
|
|
206
|
+
def enqueue_request(self, request: Request) -> bool:
|
|
207
|
+
"""Add a request to the scheduler.
|
|
208
|
+
|
|
209
|
+
This could be called from either from a spider or a downloader middleware (e.g. redirect, retry, ...).
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
request: The request to add to the scheduler.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
True if the request was successfully enqueued, False otherwise.
|
|
216
|
+
"""
|
|
217
|
+
if not isinstance(self.spider, Spider):
|
|
218
|
+
raise TypeError('self.spider must be an instance of the Spider class')
|
|
219
|
+
|
|
220
|
+
apify_request = to_apify_request(request, spider=self.spider)
|
|
221
|
+
if apify_request is None:
|
|
222
|
+
logger.warning(f'Request {request} could not be converted to Apify request; skipping it.')
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
if not isinstance(self._rq, RequestQueue):
|
|
226
|
+
raise TypeError('self._rq must be an instance of the RequestQueue class')
|
|
227
|
+
|
|
228
|
+
# Log here before re-raising: this coroutine ran on a separate event-loop thread, and the failure is
|
|
229
|
+
# otherwise easy to lose as it crosses that thread boundary back into Scrapy's synchronous machinery.
|
|
230
|
+
try:
|
|
231
|
+
result = self._async_thread.run_coro(self._rq.add_request(apify_request))
|
|
232
|
+
except Exception:
|
|
233
|
+
logger.exception('Failed to enqueue the request to the request queue.')
|
|
234
|
+
raise
|
|
235
|
+
|
|
236
|
+
return not bool(result.was_already_present)
|
|
237
|
+
|
|
238
|
+
def next_request(self) -> Request | None:
|
|
239
|
+
"""Fetch the next request from the scheduler.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
The next request, or None if there are no more requests.
|
|
243
|
+
"""
|
|
244
|
+
if not isinstance(self._rq, RequestQueue):
|
|
245
|
+
raise TypeError('self._rq must be an instance of the RequestQueue class')
|
|
246
|
+
|
|
247
|
+
# The engine polls this method throughout the crawl, so resolving here keeps the RQ current without
|
|
248
|
+
# blocking on the round trips.
|
|
249
|
+
self._resolve_finished_requests(wait=False)
|
|
250
|
+
|
|
251
|
+
# Log here before re-raising: this coroutine ran on a separate event-loop thread, and the failure is
|
|
252
|
+
# otherwise easy to lose as it crosses that thread boundary back into Scrapy's synchronous machinery.
|
|
253
|
+
try:
|
|
254
|
+
apify_request = self._async_thread.run_coro(self._rq.fetch_next_request())
|
|
255
|
+
except Exception:
|
|
256
|
+
logger.exception('Failed to fetch the next request from the request queue.')
|
|
257
|
+
raise
|
|
258
|
+
|
|
259
|
+
if apify_request is None:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
if not isinstance(self.spider, Spider):
|
|
263
|
+
raise TypeError('self.spider must be an instance of the Spider class')
|
|
264
|
+
|
|
265
|
+
# A corrupt or legacy payload must not crash the run, and is marked as handled right away, otherwise
|
|
266
|
+
# the RQ would keep handing it back forever.
|
|
267
|
+
try:
|
|
268
|
+
scrapy_request = to_scrapy_request(apify_request, spider=self.spider)
|
|
269
|
+
except Exception as exc:
|
|
270
|
+
logger.warning(f'Failed to convert Apify request {apify_request} to a Scrapy request; skipping it: {exc}')
|
|
271
|
+
try:
|
|
272
|
+
self._async_thread.run_coro(self._rq.mark_request_as_handled(apify_request))
|
|
273
|
+
except Exception:
|
|
274
|
+
logger.exception('Failed to mark the request as handled in the request queue.')
|
|
275
|
+
raise
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
# The entry stays unresolved until Scrapy is done with the request, so a run interrupted mid-flight
|
|
279
|
+
# leaves it pending instead of silently handled.
|
|
280
|
+
self._requests_in_flight.append((apify_request, scrapy_request))
|
|
281
|
+
|
|
282
|
+
return scrapy_request
|
|
283
|
+
|
|
284
|
+
def _verify_engine_internals(self) -> None:
|
|
285
|
+
"""Fail at open time if Scrapy's engine no longer exposes what the in-flight tracking reads.
|
|
286
|
+
|
|
287
|
+
The hot path deliberately does not guard those undocumented internals: swallowing an `AttributeError`
|
|
288
|
+
there would quietly go back to marking every request as handled the moment it is handed over. The
|
|
289
|
+
scraper's slot does not exist yet when the scheduler opens, so this only proves `downloader.active` and
|
|
290
|
+
`scraper.slot` are there; a missing `slot.active` surfaces from `next_request` on the first request.
|
|
291
|
+
|
|
292
|
+
Raises:
|
|
293
|
+
RuntimeError: If the engine internals the tracking relies on cannot be read.
|
|
294
|
+
"""
|
|
295
|
+
try:
|
|
296
|
+
self._requests_busy_in_scrapy()
|
|
297
|
+
except AttributeError as exc:
|
|
298
|
+
raise RuntimeError(
|
|
299
|
+
f'{ApifyScheduler.__qualname__} cannot tell which requests Scrapy is working on: this Scrapy '
|
|
300
|
+
f'version ({scrapy_version}) does not expose the engine internals it reads. Please report this '
|
|
301
|
+
'at https://github.com/apify/apify-sdk-python/issues.'
|
|
302
|
+
) from exc
|
|
303
|
+
|
|
304
|
+
def _requests_busy_in_scrapy(self) -> set[Request]:
|
|
305
|
+
"""Return the requests Scrapy is still working on.
|
|
306
|
+
|
|
307
|
+
A request joins the downloader's active set before the middleware chain runs and leaves the scraper's
|
|
308
|
+
only once the callback and the item pipeline are done, so absence from both means Scrapy is done with
|
|
309
|
+
it - downloaded, dropped by a middleware or errored out alike.
|
|
310
|
+
|
|
311
|
+
Without a crawler there is nothing to ask and every request reads as finished; `open` warns about that.
|
|
312
|
+
"""
|
|
313
|
+
engine = self._crawler.engine if self._crawler is not None else None
|
|
314
|
+
if engine is None:
|
|
315
|
+
return set()
|
|
316
|
+
|
|
317
|
+
scraper_slot = engine.scraper.slot
|
|
318
|
+
return engine.downloader.active | (scraper_slot.active if scraper_slot is not None else set())
|
|
319
|
+
|
|
320
|
+
def _resolve_finished_requests(self, *, wait: bool) -> None:
|
|
321
|
+
"""Mark every request Scrapy has finished processing as handled in the RQ.
|
|
322
|
+
|
|
323
|
+
A whole pass reaches the RQ in a single round trip. A request whose update cannot be dispatched, or
|
|
324
|
+
turns out to have failed, stays tracked for the next call to retry.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
wait: Whether to block until the RQ has been updated. Pass False on the crawl's hot path, where
|
|
328
|
+
nothing depends on the result and blocking would stall the Twisted reactor.
|
|
329
|
+
"""
|
|
330
|
+
rq = self._rq
|
|
331
|
+
if not isinstance(rq, RequestQueue) or not (self._requests_in_flight or self._pending_marks):
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
# Updates dispatched by an earlier pass that did not land are marked again by this one.
|
|
335
|
+
finished = self._collect_failed_marks(wait=wait)
|
|
336
|
+
unresolved: list[InFlightRequest] = []
|
|
337
|
+
|
|
338
|
+
if self._requests_in_flight:
|
|
339
|
+
busy = self._requests_busy_in_scrapy()
|
|
340
|
+
for apify_request, scrapy_request in self._requests_in_flight:
|
|
341
|
+
if scrapy_request in busy:
|
|
342
|
+
unresolved.append((apify_request, scrapy_request))
|
|
343
|
+
else:
|
|
344
|
+
finished.append((apify_request, scrapy_request))
|
|
345
|
+
|
|
346
|
+
if finished:
|
|
347
|
+
marks = _gather_failures(rq.mark_request_as_handled(apify_request) for apify_request, _ in finished)
|
|
348
|
+
try:
|
|
349
|
+
if wait:
|
|
350
|
+
unresolved.extend(self._failed_marks(finished, self._async_thread.run_coro(marks)))
|
|
351
|
+
else:
|
|
352
|
+
self._pending_marks.append((finished, self._async_thread.submit_coro(marks)))
|
|
353
|
+
except Exception:
|
|
354
|
+
logger.exception(f'Failed to mark {len(finished)} request(s) as handled in the request queue.')
|
|
355
|
+
unresolved.extend(finished)
|
|
356
|
+
|
|
357
|
+
self._requests_in_flight = unresolved
|
|
358
|
+
|
|
359
|
+
def _collect_failed_marks(self, *, wait: bool) -> list[InFlightRequest]:
|
|
360
|
+
"""Return the requests whose already dispatched mark-as-handled did not land, so it can be retried.
|
|
361
|
+
|
|
362
|
+
`submit_coro` reports nothing back to the reactor thread, so an update failing after dispatch would
|
|
363
|
+
otherwise drop its request from the tracking and leave it in progress in the RQ forever, which would
|
|
364
|
+
keep the RQ from ever reporting itself finished.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
wait: Whether to block until every dispatched update has finished. Updates still running are kept
|
|
368
|
+
for the next call.
|
|
369
|
+
"""
|
|
370
|
+
if not self._pending_marks:
|
|
371
|
+
return []
|
|
372
|
+
|
|
373
|
+
if wait:
|
|
374
|
+
self._async_thread.wait_for_submitted()
|
|
375
|
+
|
|
376
|
+
failed: list[InFlightRequest] = []
|
|
377
|
+
pending: list[tuple[list[InFlightRequest], Future]] = []
|
|
378
|
+
|
|
379
|
+
for requests, future in self._pending_marks:
|
|
380
|
+
if not future.done():
|
|
381
|
+
pending.append((requests, future))
|
|
382
|
+
elif future.cancelled():
|
|
383
|
+
logger.error(f'Marking {len(requests)} request(s) as handled was cancelled before it finished.')
|
|
384
|
+
failed.extend(requests)
|
|
385
|
+
else:
|
|
386
|
+
# `_gather_failures` reports a failed update in its result rather than raising, so an exception
|
|
387
|
+
# here means the dispatch itself did not survive the event loop being torn down under it.
|
|
388
|
+
try:
|
|
389
|
+
outcomes = future.result()
|
|
390
|
+
except Exception:
|
|
391
|
+
logger.exception(f'Failed to mark {len(requests)} request(s) as handled in the request queue.')
|
|
392
|
+
failed.extend(requests)
|
|
393
|
+
else:
|
|
394
|
+
failed.extend(self._failed_marks(requests, outcomes))
|
|
395
|
+
|
|
396
|
+
self._pending_marks = pending
|
|
397
|
+
|
|
398
|
+
return failed
|
|
399
|
+
|
|
400
|
+
@staticmethod
|
|
401
|
+
def _failed_marks(
|
|
402
|
+
requests: list[InFlightRequest],
|
|
403
|
+
outcomes: list[BaseException | None],
|
|
404
|
+
) -> list[InFlightRequest]:
|
|
405
|
+
"""Pair a batch of updates back with their requests, returning and logging the ones that failed."""
|
|
406
|
+
failed = []
|
|
407
|
+
|
|
408
|
+
for (apify_request, scrapy_request), outcome in zip(requests, outcomes, strict=True):
|
|
409
|
+
if outcome is not None:
|
|
410
|
+
logger.error(
|
|
411
|
+
f'Failed to mark the request {apify_request} as handled in the request queue.',
|
|
412
|
+
exc_info=outcome,
|
|
413
|
+
)
|
|
414
|
+
failed.append((apify_request, scrapy_request))
|
|
415
|
+
|
|
416
|
+
return failed
|
{apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_request_queue_single_client.py
RENAMED
|
@@ -82,7 +82,10 @@ class ApifyRequestQueueSingleClient:
|
|
|
82
82
|
"""LRU cache storing unhandled request objects, keyed by request ID."""
|
|
83
83
|
|
|
84
84
|
self._head_requests: deque[str] = deque()
|
|
85
|
-
"""Ordered queue of request IDs representing the local estimate of the queue head.
|
|
85
|
+
"""Ordered queue of request IDs representing the local estimate of the queue head.
|
|
86
|
+
|
|
87
|
+
Served from the right end, so `append` places a request at the top of the head and `appendleft` at the bottom.
|
|
88
|
+
"""
|
|
86
89
|
|
|
87
90
|
self._requests_already_handled: set[str] = set()
|
|
88
91
|
"""Set of request IDs known to be already processed on the platform.
|
|
@@ -303,9 +306,12 @@ class ApifyRequestQueueSingleClient:
|
|
|
303
306
|
# No longer handled
|
|
304
307
|
self._requests_already_handled.discard(request_id)
|
|
305
308
|
|
|
309
|
+
# `is_empty` reads only the head estimation, so the reclaimed request has to go back into it until the
|
|
310
|
+
# platform head listing catches up. Placement mirrors `add_batch_of_requests`.
|
|
306
311
|
if forefront:
|
|
307
|
-
# Append to top of the local head estimation
|
|
308
312
|
self._head_requests.append(request_id)
|
|
313
|
+
else:
|
|
314
|
+
self._head_requests.appendleft(request_id)
|
|
309
315
|
|
|
310
316
|
processed_request = await self._update_request(request, forefront=forefront)
|
|
311
317
|
processed_request.id = request_id
|
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from datetime import timedelta
|
|
4
|
-
from logging import getLogger
|
|
5
|
-
from typing import TYPE_CHECKING
|
|
6
|
-
|
|
7
|
-
from scrapy import Spider
|
|
8
|
-
from scrapy.core.scheduler import BaseScheduler
|
|
9
|
-
from scrapy.utils.reactor import is_asyncio_reactor_installed
|
|
10
|
-
|
|
11
|
-
from ._async_thread import AsyncThread
|
|
12
|
-
from .requests import to_apify_request, to_scrapy_request
|
|
13
|
-
from apify import Configuration
|
|
14
|
-
from apify.storage_clients import ApifyStorageClient
|
|
15
|
-
from apify.storages import RequestQueue
|
|
16
|
-
|
|
17
|
-
if TYPE_CHECKING:
|
|
18
|
-
from scrapy.crawler import Crawler
|
|
19
|
-
from scrapy.http.request import Request
|
|
20
|
-
from twisted.internet.defer import Deferred
|
|
21
|
-
|
|
22
|
-
logger = getLogger(__name__)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class ApifyScheduler(BaseScheduler):
|
|
26
|
-
"""A Scrapy scheduler that uses the Apify `RequestQueue` to manage requests.
|
|
27
|
-
|
|
28
|
-
This scheduler requires the asyncio Twisted reactor to be installed.
|
|
29
|
-
"""
|
|
30
|
-
|
|
31
|
-
def __init__(self, async_thread_timeout: timedelta = timedelta(seconds=60)) -> None:
|
|
32
|
-
if not is_asyncio_reactor_installed():
|
|
33
|
-
raise ValueError(
|
|
34
|
-
f'{ApifyScheduler.__qualname__} requires the asyncio Twisted reactor. '
|
|
35
|
-
'Make sure you have it configured in the TWISTED_REACTOR setting. See the asyncio '
|
|
36
|
-
'documentation of Scrapy for more information.',
|
|
37
|
-
)
|
|
38
|
-
self._rq: RequestQueue | None = None
|
|
39
|
-
self.spider: Spider | None = None
|
|
40
|
-
|
|
41
|
-
# A thread with the asyncio event loop to run coroutines on.
|
|
42
|
-
self._async_thread = AsyncThread(default_timeout=async_thread_timeout)
|
|
43
|
-
|
|
44
|
-
@classmethod
|
|
45
|
-
def from_crawler(cls, crawler: Crawler) -> ApifyScheduler:
|
|
46
|
-
"""Create the scheduler, reading the async-thread timeout from the Scrapy settings.
|
|
47
|
-
|
|
48
|
-
The `APIFY_ASYNC_THREAD_TIMEOUT_SECS` setting (in seconds) caps how long each coroutine run on the
|
|
49
|
-
background event loop may take before timing out; it defaults to 60 seconds.
|
|
50
|
-
"""
|
|
51
|
-
timeout_secs = crawler.settings.getint('APIFY_ASYNC_THREAD_TIMEOUT_SECS', 60)
|
|
52
|
-
return cls(async_thread_timeout=timedelta(seconds=timeout_secs))
|
|
53
|
-
|
|
54
|
-
def open(self, spider: Spider) -> Deferred[None] | None:
|
|
55
|
-
"""Open the scheduler.
|
|
56
|
-
|
|
57
|
-
Args:
|
|
58
|
-
spider: The spider that the scheduler is associated with.
|
|
59
|
-
"""
|
|
60
|
-
self.spider = spider
|
|
61
|
-
|
|
62
|
-
async def open_rq() -> RequestQueue:
|
|
63
|
-
configuration = Configuration.get_global_configuration()
|
|
64
|
-
if configuration.is_at_home:
|
|
65
|
-
storage_client = ApifyStorageClient()
|
|
66
|
-
return await RequestQueue.open(
|
|
67
|
-
configuration=configuration,
|
|
68
|
-
storage_client=storage_client,
|
|
69
|
-
)
|
|
70
|
-
return await RequestQueue.open()
|
|
71
|
-
|
|
72
|
-
try:
|
|
73
|
-
self._rq = self._async_thread.run_coro(open_rq())
|
|
74
|
-
except Exception:
|
|
75
|
-
logger.exception('Failed to open the request queue.')
|
|
76
|
-
# Close the freshly started async thread so a failed open does not leak its event-loop thread.
|
|
77
|
-
# Guard the close so a secondary failure here cannot mask the original error.
|
|
78
|
-
try:
|
|
79
|
-
self._async_thread.close()
|
|
80
|
-
except Exception:
|
|
81
|
-
logger.exception('Failed to close the async thread after a failed scheduler open.')
|
|
82
|
-
raise
|
|
83
|
-
|
|
84
|
-
return None
|
|
85
|
-
|
|
86
|
-
def close(self, reason: str) -> None:
|
|
87
|
-
"""Close the scheduler.
|
|
88
|
-
|
|
89
|
-
Shut down the event loop and its thread gracefully.
|
|
90
|
-
|
|
91
|
-
Args:
|
|
92
|
-
reason: The reason for closing the spider.
|
|
93
|
-
"""
|
|
94
|
-
logger.debug(f'Closing {self.__class__.__name__} due to {reason}...')
|
|
95
|
-
try:
|
|
96
|
-
self._async_thread.close()
|
|
97
|
-
|
|
98
|
-
except KeyboardInterrupt:
|
|
99
|
-
logger.warning('Shutdown interrupted by KeyboardInterrupt!')
|
|
100
|
-
|
|
101
|
-
except Exception:
|
|
102
|
-
logger.exception('Exception occurred while shutting down.')
|
|
103
|
-
|
|
104
|
-
finally:
|
|
105
|
-
logger.debug(f'{self.__class__.__name__} closed successfully.')
|
|
106
|
-
|
|
107
|
-
def has_pending_requests(self) -> bool:
|
|
108
|
-
"""Check if the scheduler has any pending requests.
|
|
109
|
-
|
|
110
|
-
Returns:
|
|
111
|
-
True if the scheduler has any pending requests, False otherwise.
|
|
112
|
-
"""
|
|
113
|
-
if not isinstance(self._rq, RequestQueue):
|
|
114
|
-
raise TypeError('self._rq must be an instance of the RequestQueue class')
|
|
115
|
-
|
|
116
|
-
# Log here before re-raising: this coroutine ran on a separate event-loop thread, and the failure is
|
|
117
|
-
# otherwise easy to lose as it crosses that thread boundary back into Scrapy's synchronous machinery.
|
|
118
|
-
try:
|
|
119
|
-
is_finished = self._async_thread.run_coro(self._rq.is_finished())
|
|
120
|
-
except Exception:
|
|
121
|
-
logger.exception('Failed to check whether the request queue is finished.')
|
|
122
|
-
raise
|
|
123
|
-
|
|
124
|
-
return not is_finished
|
|
125
|
-
|
|
126
|
-
def enqueue_request(self, request: Request) -> bool:
|
|
127
|
-
"""Add a request to the scheduler.
|
|
128
|
-
|
|
129
|
-
This could be called from either from a spider or a downloader middleware (e.g. redirect, retry, ...).
|
|
130
|
-
|
|
131
|
-
Args:
|
|
132
|
-
request: The request to add to the scheduler.
|
|
133
|
-
|
|
134
|
-
Returns:
|
|
135
|
-
True if the request was successfully enqueued, False otherwise.
|
|
136
|
-
"""
|
|
137
|
-
if not isinstance(self.spider, Spider):
|
|
138
|
-
raise TypeError('self.spider must be an instance of the Spider class')
|
|
139
|
-
|
|
140
|
-
apify_request = to_apify_request(request, spider=self.spider)
|
|
141
|
-
if apify_request is None:
|
|
142
|
-
logger.warning(f'Request {request} could not be converted to Apify request; skipping it.')
|
|
143
|
-
return False
|
|
144
|
-
|
|
145
|
-
if not isinstance(self._rq, RequestQueue):
|
|
146
|
-
raise TypeError('self._rq must be an instance of the RequestQueue class')
|
|
147
|
-
|
|
148
|
-
# Log here before re-raising: this coroutine ran on a separate event-loop thread, and the failure is
|
|
149
|
-
# otherwise easy to lose as it crosses that thread boundary back into Scrapy's synchronous machinery.
|
|
150
|
-
try:
|
|
151
|
-
result = self._async_thread.run_coro(self._rq.add_request(apify_request))
|
|
152
|
-
except Exception:
|
|
153
|
-
logger.exception('Failed to enqueue the request to the request queue.')
|
|
154
|
-
raise
|
|
155
|
-
|
|
156
|
-
return not bool(result.was_already_present)
|
|
157
|
-
|
|
158
|
-
def next_request(self) -> Request | None:
|
|
159
|
-
"""Fetch the next request from the scheduler.
|
|
160
|
-
|
|
161
|
-
Returns:
|
|
162
|
-
The next request, or None if there are no more requests.
|
|
163
|
-
"""
|
|
164
|
-
if not isinstance(self._rq, RequestQueue):
|
|
165
|
-
raise TypeError('self._rq must be an instance of the RequestQueue class')
|
|
166
|
-
|
|
167
|
-
# Log here before re-raising: this coroutine ran on a separate event-loop thread, and the failure is
|
|
168
|
-
# otherwise easy to lose as it crosses that thread boundary back into Scrapy's synchronous machinery.
|
|
169
|
-
try:
|
|
170
|
-
apify_request = self._async_thread.run_coro(self._rq.fetch_next_request())
|
|
171
|
-
except Exception:
|
|
172
|
-
logger.exception('Failed to fetch the next request from the request queue.')
|
|
173
|
-
raise
|
|
174
|
-
|
|
175
|
-
if apify_request is None:
|
|
176
|
-
return None
|
|
177
|
-
|
|
178
|
-
if not isinstance(self.spider, Spider):
|
|
179
|
-
raise TypeError('self.spider must be an instance of the Spider class')
|
|
180
|
-
|
|
181
|
-
# Reconstruct the Scrapy request before consuming the queue entry. A malformed entry must not crash
|
|
182
|
-
# the whole run, so on failure it is logged and skipped (None) rather than propagating.
|
|
183
|
-
try:
|
|
184
|
-
scrapy_request = to_scrapy_request(apify_request, spider=self.spider)
|
|
185
|
-
except Exception as exc:
|
|
186
|
-
logger.warning(f'Failed to convert Apify request {apify_request} to a Scrapy request; skipping it: {exc}')
|
|
187
|
-
scrapy_request = None
|
|
188
|
-
|
|
189
|
-
# Mark the request as handled. This runs even when reconstruction failed above: an unrecoverable entry
|
|
190
|
-
# (a corrupt or legacy payload) must still be consumed, otherwise the queue would keep handing it back
|
|
191
|
-
# forever. Retrying genuine failures is the RetryMiddleware's job.
|
|
192
|
-
# Log here before re-raising: this coroutine ran on a separate event-loop thread, and the failure is
|
|
193
|
-
# otherwise easy to lose as it crosses that thread boundary back into Scrapy's synchronous machinery.
|
|
194
|
-
try:
|
|
195
|
-
self._async_thread.run_coro(self._rq.mark_request_as_handled(apify_request))
|
|
196
|
-
except Exception:
|
|
197
|
-
logger.exception('Failed to mark the request as handled in the request queue.')
|
|
198
|
-
raise
|
|
199
|
-
|
|
200
|
-
if scrapy_request is None:
|
|
201
|
-
return None
|
|
202
|
-
|
|
203
|
-
return scrapy_request
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_apify/_request_queue_shared_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{apify-4.0.2b2 → apify-4.0.2b4}/src/apify/storage_clients/_file_system/_key_value_store_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|