scrapely-python 0.1.31__tar.gz → 0.1.33__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.
- scrapely_python-0.1.33/CHANGELOG.md +24 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/NOTICE +1 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/PKG-INFO +1 -1
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/pyproject.toml +1 -1
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/__init__.py +1 -1
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/actor.py +30 -5
- scrapely_python-0.1.33/src/scrapely/scrapy/__init__.py +36 -0
- scrapely_python-0.1.33/src/scrapely/scrapy/_actor_runner.py +43 -0
- scrapely_python-0.1.33/src/scrapely/scrapy/_async_thread.py +209 -0
- scrapely_python-0.1.33/src/scrapely/scrapy/_logging_config.py +70 -0
- scrapely_python-0.1.33/src/scrapely/scrapy/_serialization.py +147 -0
- scrapely_python-0.1.33/src/scrapely/scrapy/_warnings.py +26 -0
- scrapely_python-0.1.33/src/scrapely/scrapy/extensions/__init__.py +8 -0
- scrapely_python-0.1.33/src/scrapely/scrapy/extensions/_graceful_stop.py +59 -0
- scrapely_python-0.1.33/src/scrapely/scrapy/extensions/_httpcache.py +321 -0
- scrapely_python-0.1.33/src/scrapely/scrapy/middlewares/__init__.py +7 -0
- scrapely_python-0.1.33/src/scrapely/scrapy/middlewares/apify_proxy.py +140 -0
- scrapely_python-0.1.33/src/scrapely/scrapy/pipelines/__init__.py +7 -0
- scrapely_python-0.1.33/src/scrapely/scrapy/pipelines/actor_dataset_push.py +30 -0
- scrapely_python-0.1.33/src/scrapely/scrapy/requests.py +254 -0
- scrapely_python-0.1.33/src/scrapely/scrapy/scheduler.py +500 -0
- scrapely_python-0.1.33/src/scrapely/scrapy/utils.py +60 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely_python.egg-info/PKG-INFO +1 -1
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely_python.egg-info/SOURCES.txt +16 -0
- scrapely_python-0.1.31/CHANGELOG.md +0 -16
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/LICENSE +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/LICENSE-APACHE +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/MANIFEST.in +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/README.md +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/setup.cfg +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/_browser_locations.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/browser_configuration.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/cdp_retry.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/charging.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/charging_client.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/configuration.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/crypto/__init__.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/crypto/_secrets.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/errors.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/events/__init__.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/events/_types.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/events/event_manager.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/log.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/patch_browser_pool.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/pricing_types.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/proxy_configuration.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/py.typed +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/request_loaders/__init__.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/request_loaders/_scrapely_request_list.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/solver.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/storage/__init__.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/storage/_file_system/__init__.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/storage/_file_system/_key_value_store_client.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/storage/_file_system/_storage_client.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/storage/_metadata.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/storage/api_client.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/storage/client.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/storage/patch_storage.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/storage/scrapely_dataset_client.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/storage/scrapely_kvs_client.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/storage/scrapely_rq_client.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/storage/scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/storage/smart_scrapely_storage_client.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/storage/types.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/storage/utils.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/storage_clients/__init__.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/storages/__init__.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/types.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely/webhook.py +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely_python.egg-info/dependency_links.txt +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely_python.egg-info/requires.txt +0 -0
- {scrapely_python-0.1.31 → scrapely_python-0.1.33}/src/scrapely_python.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.1.33] - 2026-10-22
|
|
6
|
+
|
|
7
|
+
- Debug captures (`Actor.debug_capture`, `Actor.debug_capture_sync`) keep payloads over 256 KiB whole. They are stored gzip-compressed and base64-encoded as `{key, payload, encoding: 'gzip-base64', length, truncated}` instead of being cut at 262,144 characters; up to 8 Mi characters are kept. A payload that does not compress enough keeps its first 256 KiB, now marked `truncated: True` with its full `length`. Dict and list payloads are dumped as compact JSON, and `bytes` payloads are stored as UTF-8 text (they used to be stored as their `repr`). Captures are only active on the platform's own diagnostic runs (`SCRAPELY_DEBUG_CAPTURE=1`).
|
|
8
|
+
|
|
9
|
+
## [0.1.32] - 2026-10-20
|
|
10
|
+
|
|
11
|
+
- Added `scrapely.scrapy`, the Scrapy integration: `run_scrapy_actor`, `initialize_logging`, `apply_apify_settings`, a scheduler backed by the run's request queue, a pipeline that pushes items to the default dataset, a proxy middleware, an HTTP cache storage and an extension that stops the crawl gracefully when the run is aborted. Queued Scrapy requests and cached responses are stored as JSON, never pickled, and a stored request only rebuilds as a `scrapy.Request` subclass. A request leaves the queue as handled only once Scrapy has finished it, so a migrated or aborted run does not lose or repeat it. Scrapy is imported only when these are used.
|
|
12
|
+
|
|
13
|
+
## [0.1.31] - 2026-10-20
|
|
14
|
+
|
|
15
|
+
- The storage client percent-encodes request ids and key-value store record keys as one URL path segment and refuses `''`, `.` and `..`. A request id such as `..` read from a queue used to send the call to another resource.
|
|
16
|
+
|
|
17
|
+
## [0.1.30] - 2026-10-20
|
|
18
|
+
|
|
19
|
+
- Removed `apify_margin_percentage` from the pricing info models; the API no longer returns it.
|
|
20
|
+
- Added Apache-2.0 change notices to the storage clients, storage utils and log module derived from apify-sdk-python, and listed them in NOTICE.
|
|
21
|
+
|
|
22
|
+
## [0.1.29] - 2026-10-19
|
|
23
|
+
|
|
24
|
+
- Initial public release.
|
|
@@ -10,6 +10,7 @@ Version 2.0 (see LICENSE-APACHE). They have been modified by TeraShift GmbH:
|
|
|
10
10
|
- src/scrapely/events/event_manager.py
|
|
11
11
|
- src/scrapely/log.py
|
|
12
12
|
- src/scrapely/request_loaders/_scrapely_request_list.py
|
|
13
|
+
- src/scrapely/scrapy/ (all files)
|
|
13
14
|
- src/scrapely/storage/_file_system/_key_value_store_client.py
|
|
14
15
|
- src/scrapely/storage/_file_system/_storage_client.py
|
|
15
16
|
- src/scrapely/storage/smart_scrapely_storage_client.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "scrapely-python"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.33"
|
|
8
8
|
description = "Scrapely Python SDK - A toolkit for building Actors on the Scrapely platform"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT AND Apache-2.0"
|
|
@@ -925,7 +925,14 @@ class Actor(metaclass=_ActorMeta):
|
|
|
925
925
|
|
|
926
926
|
# Debug capture: raw source payloads for platform diagnostics. Only active when the platform
|
|
927
927
|
# sets SCRAPELY_DEBUG_CAPTURE=1 on its own diagnostic runs; a no-op in every other run.
|
|
928
|
+
# A payload up to _DEBUG_CAPTURE_MAX_BYTES characters is stored as it is; a larger one gzip-compressed
|
|
929
|
+
# (base64, at most _DEBUG_CAPTURE_MAX_BYTES characters again), keeping up to _DEBUG_CAPTURE_MAX_CHARS
|
|
930
|
+
# characters / _DEBUG_CAPTURE_MAX_ENCODED bytes of it. A plain cut at 256 KiB lost the part a check
|
|
931
|
+
# needed when a large API document starts with site navigation (the seller section of a 581 KB
|
|
932
|
+
# listing JSON sat at ~333 KB).
|
|
928
933
|
_DEBUG_CAPTURE_MAX_BYTES = 256 * 1024
|
|
934
|
+
_DEBUG_CAPTURE_MAX_CHARS = 8 * 1024 * 1024
|
|
935
|
+
_DEBUG_CAPTURE_MAX_ENCODED = 8 * 1024 * 1024
|
|
929
936
|
_DEBUG_CAPTURE_MAX_RECORDS = 500
|
|
930
937
|
_debug_capture_count = 0
|
|
931
938
|
|
|
@@ -967,15 +974,33 @@ class Actor(metaclass=_ActorMeta):
|
|
|
967
974
|
if cls._debug_capture_count >= cls._DEBUG_CAPTURE_MAX_RECORDS:
|
|
968
975
|
return
|
|
969
976
|
try:
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
977
|
+
if isinstance(payload, (bytes, bytearray)):
|
|
978
|
+
text = bytes(payload).decode('utf-8', 'replace')
|
|
979
|
+
elif isinstance(payload, str):
|
|
980
|
+
text = payload
|
|
981
|
+
else:
|
|
982
|
+
text = json.dumps(payload, default=str, ensure_ascii=False, separators=(',', ':'))
|
|
983
|
+
record = cls._debug_capture_record(str(key), text)
|
|
974
984
|
cls._debug_capture_count += 1
|
|
975
|
-
await cls.set_value(cls.debug_capture_record_key(key),
|
|
985
|
+
await cls.set_value(cls.debug_capture_record_key(key), record)
|
|
976
986
|
except Exception as err: # never break the run
|
|
977
987
|
logging.getLogger('scrapely').debug('debug_capture failed: %s', err)
|
|
978
988
|
|
|
989
|
+
@classmethod
|
|
990
|
+
def _debug_capture_record(cls, key: str, text: str) -> Dict[str, Any]:
|
|
991
|
+
"""The record for one capture: plain when small, else gzip-base64 (plain head as the last resort)."""
|
|
992
|
+
limit = cls._DEBUG_CAPTURE_MAX_BYTES
|
|
993
|
+
if len(text) <= limit:
|
|
994
|
+
return {'key': key, 'payload': text}
|
|
995
|
+
import gzip
|
|
996
|
+
raw = text[: cls._DEBUG_CAPTURE_MAX_CHARS].encode('utf-8')
|
|
997
|
+
kept = raw[: cls._DEBUG_CAPTURE_MAX_ENCODED]
|
|
998
|
+
packed = base64.b64encode(gzip.compress(kept, 6)).decode('ascii')
|
|
999
|
+
if len(packed) <= limit:
|
|
1000
|
+
truncated = len(text) > cls._DEBUG_CAPTURE_MAX_CHARS or len(kept) < len(raw)
|
|
1001
|
+
return {'key': key, 'payload': packed, 'encoding': 'gzip-base64', 'length': len(text), 'truncated': truncated}
|
|
1002
|
+
return {'key': key, 'payload': text[:limit], 'length': len(text), 'truncated': True}
|
|
1003
|
+
|
|
979
1004
|
@classmethod
|
|
980
1005
|
def _resolve_open_args(
|
|
981
1006
|
cls,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Derived from apify-sdk-python (src/apify/scrapy/__init__.py),
|
|
3
|
+
# Copyright Apify Technologies s.r.o., licensed under the Apache License 2.0.
|
|
4
|
+
# Modified by TeraShift GmbH. See LICENSE-APACHE and NOTICE.
|
|
5
|
+
from crawlee._utils.try_import import install_import_hook as _install_import_hook
|
|
6
|
+
from crawlee._utils.try_import import try_import as _try_import
|
|
7
|
+
|
|
8
|
+
_install_import_hook(__name__)
|
|
9
|
+
|
|
10
|
+
# The following imports use try_import to handle optional dependencies, as they may not always be available.
|
|
11
|
+
|
|
12
|
+
with _try_import(__name__, 'run_scrapy_actor'):
|
|
13
|
+
from ._actor_runner import run_scrapy_actor
|
|
14
|
+
|
|
15
|
+
with _try_import(__name__, 'initialize_logging'):
|
|
16
|
+
from ._logging_config import initialize_logging
|
|
17
|
+
|
|
18
|
+
with _try_import(__name__, 'to_apify_request', 'to_scrapy_request'):
|
|
19
|
+
from .requests import to_apify_request, to_scrapy_request
|
|
20
|
+
|
|
21
|
+
with _try_import(__name__, 'ApifyScheduler'):
|
|
22
|
+
from .scheduler import ApifyScheduler
|
|
23
|
+
|
|
24
|
+
with _try_import(__name__, 'apply_apify_settings', 'get_basic_auth_header'):
|
|
25
|
+
from .utils import apply_apify_settings, get_basic_auth_header
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
'ApifyScheduler',
|
|
30
|
+
'apply_apify_settings',
|
|
31
|
+
'get_basic_auth_header',
|
|
32
|
+
'initialize_logging',
|
|
33
|
+
'run_scrapy_actor',
|
|
34
|
+
'to_apify_request',
|
|
35
|
+
'to_scrapy_request',
|
|
36
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Derived from apify-sdk-python (src/apify/scrapy/_actor_runner.py),
|
|
3
|
+
# Copyright Apify Technologies s.r.o., licensed under the Apache License 2.0.
|
|
4
|
+
# Modified by TeraShift GmbH. See LICENSE-APACHE and NOTICE.
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Coroutine
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def run_scrapy_actor(coro: Coroutine) -> None:
|
|
15
|
+
"""Start Twisted's reactor and execute the provided Actor coroutine.
|
|
16
|
+
|
|
17
|
+
This function installs Twisted's asyncio-compatible reactor, then initiates it and runs the given asyncio
|
|
18
|
+
coroutine (typically the Actor's main) by converting it to a Deferred. This bridges the asyncio and Twisted
|
|
19
|
+
event loops, enabling the Scrapely and Scrapy integration to work together.
|
|
20
|
+
"""
|
|
21
|
+
from scrapy.utils.reactor import install_reactor # noqa: PLC0415
|
|
22
|
+
from twisted.internet.error import ReactorAlreadyInstalledError # noqa: PLC0415
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
install_reactor('twisted.internet.asyncioreactor.AsyncioSelectorReactor')
|
|
26
|
+
except ReactorAlreadyInstalledError:
|
|
27
|
+
from twisted.internet import reactor as installed_reactor # noqa: PLC0415
|
|
28
|
+
|
|
29
|
+
reactor_cls = type(installed_reactor)
|
|
30
|
+
if (
|
|
31
|
+
reactor_cls.__module__ != 'twisted.internet.asyncioreactor'
|
|
32
|
+
or reactor_cls.__name__ != 'AsyncioSelectorReactor'
|
|
33
|
+
):
|
|
34
|
+
raise RuntimeError(
|
|
35
|
+
'A Twisted reactor is already installed and it is not AsyncioSelectorReactor. '
|
|
36
|
+
'Make sure that run_scrapy_actor() is called before importing any Scrapy or Twisted '
|
|
37
|
+
'modules that install a reactor (e.g. scrapy.crawler).'
|
|
38
|
+
) from None
|
|
39
|
+
|
|
40
|
+
from twisted.internet.defer import Deferred # noqa: PLC0415
|
|
41
|
+
from twisted.internet.task import react # noqa: PLC0415
|
|
42
|
+
|
|
43
|
+
react(lambda _reactor: Deferred.fromFuture(asyncio.ensure_future(coro)))
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Derived from apify-sdk-python (src/apify/scrapy/_async_thread.py),
|
|
3
|
+
# Copyright Apify Technologies s.r.o., licensed under the Apache License 2.0.
|
|
4
|
+
# Modified by TeraShift GmbH. See LICENSE-APACHE and NOTICE.
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import threading
|
|
9
|
+
from concurrent import futures
|
|
10
|
+
from datetime import timedelta
|
|
11
|
+
from logging import getLogger
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Coroutine
|
|
16
|
+
|
|
17
|
+
logger = getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
SUBMITTED_PRUNE_THRESHOLD = 128
|
|
20
|
+
"""How many `submit_coro` futures may pile up before the finished ones are dropped from the tracking list."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AsyncThread:
|
|
24
|
+
"""Run an asyncio event loop in a dedicated background thread.
|
|
25
|
+
|
|
26
|
+
This lets synchronous Scrapy callbacks drive asynchronous Scrapely and Crawlee coroutines. The
|
|
27
|
+
scheduler and the HTTP cache storage each own their own `AsyncThread`, so the request queue and
|
|
28
|
+
the key-value store never share an event loop; they only share the read-only global
|
|
29
|
+
`Configuration`. A single shared loop would also work but would couple their lifecycles.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, default_timeout: timedelta = timedelta(seconds=60)) -> None:
|
|
33
|
+
self._default_timeout = default_timeout
|
|
34
|
+
self._eventloop = asyncio.new_event_loop()
|
|
35
|
+
|
|
36
|
+
self._submitted: list[futures.Future] = []
|
|
37
|
+
"""Futures of the coroutines submitted via `submit_coro` that may still be running."""
|
|
38
|
+
|
|
39
|
+
# Start the event loop in a dedicated daemon thread.
|
|
40
|
+
self._thread = threading.Thread(
|
|
41
|
+
target=self._start_event_loop,
|
|
42
|
+
daemon=True,
|
|
43
|
+
)
|
|
44
|
+
self._thread.start()
|
|
45
|
+
|
|
46
|
+
def run_coro(
|
|
47
|
+
self,
|
|
48
|
+
coro: Coroutine,
|
|
49
|
+
timeout: timedelta | Literal['default'] = 'default',
|
|
50
|
+
) -> Any:
|
|
51
|
+
"""Run a coroutine on an event loop running in a separate thread.
|
|
52
|
+
|
|
53
|
+
This method schedules the coroutine to run on the event loop and blocks until the coroutine completes
|
|
54
|
+
or the specified timeout is reached.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
coro: The coroutine to run.
|
|
58
|
+
timeout: The maximum time to wait for the coroutine to finish. Pass `'default'` to use the
|
|
59
|
+
`default_timeout` passed to the constructor.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The result returned by the coroutine.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
RuntimeError: If the event loop has been closed.
|
|
66
|
+
TimeoutError: If the coroutine does not complete within the timeout.
|
|
67
|
+
Exception: Any exception raised during coroutine execution.
|
|
68
|
+
"""
|
|
69
|
+
if timeout == 'default':
|
|
70
|
+
timeout = self._default_timeout
|
|
71
|
+
|
|
72
|
+
if self._eventloop.is_closed():
|
|
73
|
+
raise RuntimeError(f'The coroutine {coro} cannot be executed because the event loop is closed.')
|
|
74
|
+
|
|
75
|
+
# Submit the coroutine to the event loop running in the other thread.
|
|
76
|
+
future = asyncio.run_coroutine_threadsafe(coro, self._eventloop)
|
|
77
|
+
try:
|
|
78
|
+
# Wait for the coroutine's result until the specified timeout.
|
|
79
|
+
return future.result(timeout=timeout.total_seconds())
|
|
80
|
+
except futures.TimeoutError:
|
|
81
|
+
# `future.result` gave up, but the coroutine keeps running on the loop; cancel it so it does
|
|
82
|
+
# not outlive the timeout. The propagated error is logged once by the caller (or Scrapy), so
|
|
83
|
+
# this method does not log it itself.
|
|
84
|
+
future.cancel()
|
|
85
|
+
raise
|
|
86
|
+
|
|
87
|
+
def submit_coro(self, coro: Coroutine) -> futures.Future:
|
|
88
|
+
"""Schedule a coroutine on the event loop without waiting for its result.
|
|
89
|
+
|
|
90
|
+
Use this for work nothing depends on, so the calling thread is not blocked by the round trip. Failures
|
|
91
|
+
are logged, as there is no caller to propagate them to, and `close` cancels whatever is still pending -
|
|
92
|
+
call `wait_for_submitted` first if that matters.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
coro: The coroutine to run.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
The future of the scheduled coroutine, for callers that want to inspect its outcome later.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
RuntimeError: If the event loop has been closed.
|
|
102
|
+
"""
|
|
103
|
+
if self._eventloop.is_closed():
|
|
104
|
+
raise RuntimeError(f'The coroutine {coro} cannot be executed because the event loop is closed.')
|
|
105
|
+
|
|
106
|
+
# Callers may go a long time between `wait_for_submitted` calls, so without pruning the list would hold
|
|
107
|
+
# every coroutine ever submitted, with its result.
|
|
108
|
+
if len(self._submitted) >= SUBMITTED_PRUNE_THRESHOLD:
|
|
109
|
+
self._submitted = [submitted for submitted in self._submitted if not submitted.done()]
|
|
110
|
+
|
|
111
|
+
future = asyncio.run_coroutine_threadsafe(coro, self._eventloop)
|
|
112
|
+
future.add_done_callback(self._log_failure)
|
|
113
|
+
self._submitted.append(future)
|
|
114
|
+
|
|
115
|
+
return future
|
|
116
|
+
|
|
117
|
+
def wait_for_submitted(self, timeout: timedelta | None = None) -> None:
|
|
118
|
+
"""Block until the coroutines submitted via `submit_coro` have finished.
|
|
119
|
+
|
|
120
|
+
Use this before anything that would observe their effects, or before `close`, which cancels whatever is
|
|
121
|
+
still running. Coroutines that do not finish within the timeout stay tracked for the next call.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
timeout: The maximum time to wait for the submitted coroutines. Pass `None` to use the
|
|
125
|
+
`default_timeout` passed to the constructor.
|
|
126
|
+
"""
|
|
127
|
+
if timeout is None:
|
|
128
|
+
timeout = self._default_timeout
|
|
129
|
+
|
|
130
|
+
self._submitted = list(futures.wait(self._submitted, timeout=timeout.total_seconds()).not_done)
|
|
131
|
+
|
|
132
|
+
# Callers rely on the effects having landed, so a timeout has to be visible.
|
|
133
|
+
if self._submitted:
|
|
134
|
+
logger.warning(f'{len(self._submitted)} submitted coroutines did not finish within the timeout.')
|
|
135
|
+
|
|
136
|
+
def close(self, timeout: timedelta | None = None) -> None:
|
|
137
|
+
"""Close the event loop and its thread gracefully.
|
|
138
|
+
|
|
139
|
+
This method cancels all pending tasks, stops the event loop, and waits for the thread to exit.
|
|
140
|
+
If the thread does not exit within the given timeout, a forced shutdown is attempted.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
timeout: The maximum time to wait for the event loop thread to exit. Pass `None` to use the
|
|
144
|
+
`default_timeout` passed to the constructor.
|
|
145
|
+
"""
|
|
146
|
+
if timeout is None:
|
|
147
|
+
timeout = self._default_timeout
|
|
148
|
+
|
|
149
|
+
# A repeated close (e.g. a retried shutdown) would call into the already-closed loop and raise
|
|
150
|
+
# `RuntimeError: Event loop is closed`. The loop closes itself once it stops, so a second close
|
|
151
|
+
# is a no-op.
|
|
152
|
+
if self._eventloop.is_closed():
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
if self._eventloop.is_running():
|
|
157
|
+
# Cancel all pending tasks in the event loop, honouring the caller's timeout.
|
|
158
|
+
self.run_coro(self._shutdown_tasks(), timeout=timeout)
|
|
159
|
+
finally:
|
|
160
|
+
# Stop the loop and join its thread even if cancelling the pending tasks above raised or timed
|
|
161
|
+
# out. Skipping this would leave the loop running and leak its thread.
|
|
162
|
+
self._eventloop.call_soon_threadsafe(self._eventloop.stop)
|
|
163
|
+
|
|
164
|
+
# Wait for the event loop thread to finish execution.
|
|
165
|
+
self._thread.join(timeout=timeout.total_seconds())
|
|
166
|
+
|
|
167
|
+
# If the thread is still running after the timeout, force a shutdown.
|
|
168
|
+
if self._thread.is_alive():
|
|
169
|
+
logger.warning('Event loop thread did not exit cleanly! Forcing shutdown...')
|
|
170
|
+
self._force_exit_event_loop()
|
|
171
|
+
|
|
172
|
+
@staticmethod
|
|
173
|
+
def _log_failure(future: futures.Future) -> None:
|
|
174
|
+
"""Log the failure of a coroutine submitted via `submit_coro`."""
|
|
175
|
+
if future.cancelled():
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
if (exc := future.exception()) is not None:
|
|
179
|
+
logger.error('A coroutine submitted to the event loop failed.', exc_info=exc)
|
|
180
|
+
|
|
181
|
+
def _start_event_loop(self) -> None:
|
|
182
|
+
"""Set up and run the asyncio event loop in the dedicated thread."""
|
|
183
|
+
asyncio.set_event_loop(self._eventloop)
|
|
184
|
+
try:
|
|
185
|
+
self._eventloop.run_forever()
|
|
186
|
+
finally:
|
|
187
|
+
self._eventloop.close()
|
|
188
|
+
logger.debug('Asyncio event loop has been closed.')
|
|
189
|
+
|
|
190
|
+
async def _shutdown_tasks(self) -> None:
|
|
191
|
+
"""Cancel all pending tasks in the event loop."""
|
|
192
|
+
# Retrieve all tasks for the event loop, excluding the current task.
|
|
193
|
+
tasks = [task for task in asyncio.all_tasks(self._eventloop) if task is not asyncio.current_task()]
|
|
194
|
+
|
|
195
|
+
# Cancel each pending task.
|
|
196
|
+
for task in tasks:
|
|
197
|
+
task.cancel()
|
|
198
|
+
|
|
199
|
+
# Wait until all tasks have been cancelled or finished.
|
|
200
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
201
|
+
|
|
202
|
+
def _force_exit_event_loop(self) -> None:
|
|
203
|
+
"""Shut down the event loop and its thread forcefully."""
|
|
204
|
+
try:
|
|
205
|
+
logger.info('Forced shutdown of the event loop and its thread...')
|
|
206
|
+
self._eventloop.call_soon_threadsafe(self._eventloop.stop)
|
|
207
|
+
self._thread.join(timeout=5)
|
|
208
|
+
except Exception:
|
|
209
|
+
logger.exception('Exception occurred during forced event loop shutdown.')
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Derived from apify-sdk-python (src/apify/scrapy/_logging_config.py),
|
|
3
|
+
# Copyright Apify Technologies s.r.o., licensed under the Apache License 2.0.
|
|
4
|
+
# Modified by TeraShift GmbH. See LICENSE-APACHE and NOTICE.
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from scrapy.utils import log as scrapy_logging
|
|
11
|
+
from scrapy.utils.project import get_project_settings
|
|
12
|
+
|
|
13
|
+
from scrapely.log import ActorLogFormatter
|
|
14
|
+
|
|
15
|
+
# Define logger names.
|
|
16
|
+
_PRIMARY_LOGGERS = ['scrapely', 'scrapely_client', 'scrapy']
|
|
17
|
+
_SUPPLEMENTAL_LOGGERS = ['filelock', 'hpack', 'httpcore', 'protego', 'twisted']
|
|
18
|
+
_ALL_LOGGERS = _PRIMARY_LOGGERS + _SUPPLEMENTAL_LOGGERS
|
|
19
|
+
|
|
20
|
+
# Mutable state shared with the Scrapy monkey-patch below. `initialize_logging` refreshes
|
|
21
|
+
# `level`/`handler` on each call; the patch (installed once) reads them so it always applies the
|
|
22
|
+
# latest configuration rather than values captured the first time it ran.
|
|
23
|
+
_state: dict[str, Any] = {'level': 'INFO', 'handler': None, 'patched': False}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _configure_logger(name: str | None, logging_level: str, handler: logging.Handler) -> None:
|
|
27
|
+
"""Clear and reconfigure the logger."""
|
|
28
|
+
logger = logging.getLogger(name)
|
|
29
|
+
logger.handlers.clear()
|
|
30
|
+
logger.setLevel(logging_level)
|
|
31
|
+
logger.addHandler(handler)
|
|
32
|
+
logger.propagate = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _configure_all_loggers() -> None:
|
|
36
|
+
"""Apply the Scrapely handler and level to the root logger and all defined loggers."""
|
|
37
|
+
handler = _state['handler']
|
|
38
|
+
if handler is None:
|
|
39
|
+
return
|
|
40
|
+
for logger_name in [None, *_ALL_LOGGERS]:
|
|
41
|
+
_configure_logger(logger_name, _state['level'], handler)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def initialize_logging() -> None:
|
|
45
|
+
"""Configure logging for Scrapely Actors and adjust Scrapy's logging settings."""
|
|
46
|
+
# Retrieve Scrapy project settings and determine the logging level.
|
|
47
|
+
settings = get_project_settings()
|
|
48
|
+
_state['level'] = settings.get('LOG_LEVEL', 'INFO') # Default to INFO.
|
|
49
|
+
|
|
50
|
+
# Create a custom handler with the Scrapely log formatter.
|
|
51
|
+
handler = logging.StreamHandler()
|
|
52
|
+
handler.setFormatter(ActorLogFormatter(include_logger_name=True))
|
|
53
|
+
_state['handler'] = handler
|
|
54
|
+
|
|
55
|
+
# Configure the root logger and all other defined loggers.
|
|
56
|
+
_configure_all_loggers()
|
|
57
|
+
|
|
58
|
+
# Monkey-patch Scrapy's logging to re-apply our settings whenever it reconfigures logging.
|
|
59
|
+
# Install the wrapper at most once, otherwise repeated calls would nest wrappers.
|
|
60
|
+
if _state['patched']:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
original_configure_logging = scrapy_logging.configure_logging
|
|
64
|
+
|
|
65
|
+
def new_configure_logging(*args: Any, **kwargs: Any) -> None:
|
|
66
|
+
original_configure_logging(*args, **kwargs)
|
|
67
|
+
_configure_all_loggers()
|
|
68
|
+
|
|
69
|
+
scrapy_logging.configure_logging = new_configure_logging # ty: ignore[invalid-assignment]
|
|
70
|
+
_state['patched'] = True
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Derived from apify-sdk-python (src/apify/scrapy/_serialization.py),
|
|
3
|
+
# Copyright Apify Technologies s.r.o., licensed under the Apache License 2.0.
|
|
4
|
+
# Modified by TeraShift GmbH. See LICENSE-APACHE and NOTICE.
|
|
5
|
+
"""JSON serialization of Scrapy requests and cached responses for storage on the Scrapely platform.
|
|
6
|
+
|
|
7
|
+
Scrapy requests and cached responses are stored in the request queue and key-value store, which hold JSON.
|
|
8
|
+
They are therefore serialized as JSON here (never pickled: unpickling stored data could run arbitrary code).
|
|
9
|
+
|
|
10
|
+
Only `body` (`bytes`) and `headers` (`{bytes: [bytes]}`) are not natively JSON-serializable. Both live at fixed keys
|
|
11
|
+
and are base64-encoded in place. A `str` `body` is encoded as UTF-8 bytes and deserialized as `bytes`, matching
|
|
12
|
+
Scrapy, which always stores `body` as `bytes`.
|
|
13
|
+
|
|
14
|
+
Pydantic models, such as Crawlee's `UserData`, are dumped with `model_dump(mode='json')`, which converts
|
|
15
|
+
non-JSON-native fields into JSON-compatible values. For example, `datetime` fields are stored as ISO-8601 strings.
|
|
16
|
+
|
|
17
|
+
All other values, notably `meta` and `cb_kwargs`, must already be JSON-serializable. Non-JSON-serializable values,
|
|
18
|
+
such as `datetime`, `set`, `Decimal`, or arbitrary objects, fail serialization with a clear error naming the
|
|
19
|
+
offending value. The request is skipped rather than stored in a corrupted form.
|
|
20
|
+
|
|
21
|
+
No in-band sentinel is used, so user values cannot collide with the encoding.
|
|
22
|
+
|
|
23
|
+
Known JSON limitations: values in `meta` and `cb_kwargs` are subject to JSON coercions. A `tuple` round-trips as
|
|
24
|
+
a `list`, and non-string `dict` keys round-trip as strings; for example, `{1: 'a'}` becomes `{'1': 'a'}`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import base64
|
|
30
|
+
import json
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from pydantic import BaseModel
|
|
34
|
+
|
|
35
|
+
# Cap the offending value's repr in a serialization error message so a huge value cannot bloat the log.
|
|
36
|
+
_MAX_ERROR_VALUE_REPR_LEN = 200
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def encode_to_json(data: dict[str, Any]) -> str:
|
|
40
|
+
"""Serialize a Scrapy request/response dict to a JSON string.
|
|
41
|
+
|
|
42
|
+
The `body` and `headers` fields are base64-encoded in place (a `str` `body` via its UTF-8 bytes); pydantic
|
|
43
|
+
models are dumped to plain dicts. A `TypeError` is raised if any other value cannot be JSON-encoded.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
data: The dict to serialize, e.g. the output of `scrapy.Request.to_dict()`.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
The JSON-encoded string.
|
|
50
|
+
"""
|
|
51
|
+
if not isinstance(data, dict):
|
|
52
|
+
raise TypeError(f'Expected a dict to serialize, got {type(data)}')
|
|
53
|
+
|
|
54
|
+
safe = dict(data)
|
|
55
|
+
|
|
56
|
+
# `body` is base64-encoded so binary payloads survive; a `str` body is taken as its UTF-8 bytes, which keeps
|
|
57
|
+
# encode/decode symmetric (decode always base64-decodes `body` back to `bytes`).
|
|
58
|
+
body = safe.get('body')
|
|
59
|
+
if isinstance(body, (bytes, str)):
|
|
60
|
+
raw_body = body.encode('utf-8') if isinstance(body, str) else body
|
|
61
|
+
safe['body'] = base64.b64encode(raw_body).decode('ascii')
|
|
62
|
+
|
|
63
|
+
if isinstance(safe.get('headers'), dict):
|
|
64
|
+
safe['headers'] = _encode_headers(safe['headers'])
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
# `ensure_ascii=False` keeps non-ASCII URLs/meta as their UTF-8 form instead of `\uXXXX` escapes, which
|
|
68
|
+
# would otherwise roughly double the size of non-Latin text in storage.
|
|
69
|
+
return json.dumps(safe, default=_json_default, ensure_ascii=False)
|
|
70
|
+
# `ValueError` covers pydantic's `PydanticSerializationError`, raised when a model field cannot be dumped
|
|
71
|
+
# to JSON even in JSON mode.
|
|
72
|
+
except (TypeError, ValueError) as exc:
|
|
73
|
+
raise TypeError(
|
|
74
|
+
'Failed to JSON-serialize a Scrapy request/response for storage on the Scrapely platform. '
|
|
75
|
+
'All values in `meta` and `cb_kwargs` must be JSON-serializable (str, int, float, bool, None, '
|
|
76
|
+
'list, dict, or a pydantic model).'
|
|
77
|
+
) from exc
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def decode_from_json(text: str) -> Any:
|
|
81
|
+
"""Reconstruct a Scrapy request/response dict from a string produced by `encode_to_json`.
|
|
82
|
+
|
|
83
|
+
The base64-encoded `body` and `headers` fields are decoded back to their `bytes` representation.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
text: The JSON-encoded string.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The decoded object (a dict for valid request/response payloads).
|
|
90
|
+
"""
|
|
91
|
+
data = json.loads(text)
|
|
92
|
+
if not isinstance(data, dict):
|
|
93
|
+
return data
|
|
94
|
+
|
|
95
|
+
# `validate=True` makes a non-base64 body raise loudly instead of silently decoding to garbage.
|
|
96
|
+
if isinstance(data.get('body'), str):
|
|
97
|
+
data['body'] = base64.b64decode(data['body'], validate=True)
|
|
98
|
+
|
|
99
|
+
if isinstance(data.get('headers'), dict):
|
|
100
|
+
data['headers'] = _decode_headers(data['headers'])
|
|
101
|
+
|
|
102
|
+
return data
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _json_default(obj: Any) -> Any:
|
|
106
|
+
"""Fallback for values `json.dumps` cannot serialize: pydantic models are dumped, anything else raises.
|
|
107
|
+
|
|
108
|
+
The error names the offending value (type and a truncated repr) so a failed serialization points straight
|
|
109
|
+
at the bad `meta`/`cb_kwargs` entry instead of just reporting that something failed.
|
|
110
|
+
"""
|
|
111
|
+
if isinstance(obj, BaseModel):
|
|
112
|
+
return obj.model_dump(mode='json', by_alias=True)
|
|
113
|
+
value_repr = repr(obj)
|
|
114
|
+
if len(value_repr) > _MAX_ERROR_VALUE_REPR_LEN:
|
|
115
|
+
value_repr = value_repr[:_MAX_ERROR_VALUE_REPR_LEN] + '...'
|
|
116
|
+
raise TypeError(f'Object of type {type(obj).__name__} is not JSON-serializable: {value_repr}')
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _encode_headers(headers: dict[Any, Any]) -> dict[str, list[str]]:
|
|
120
|
+
"""Encode a Scrapy `{bytes: [bytes]}` headers mapping to a JSON-safe `{str: [base64-str]}`."""
|
|
121
|
+
encoded: dict[str, list[str]] = {}
|
|
122
|
+
for key, value in headers.items():
|
|
123
|
+
str_key = key.decode('latin-1') if isinstance(key, bytes) else key
|
|
124
|
+
values = value if isinstance(value, (list, tuple)) else [value]
|
|
125
|
+
encoded[str_key] = [_b64encode_value(item) for item in values]
|
|
126
|
+
return encoded
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _decode_headers(headers: dict[str, Any]) -> dict[bytes, list[bytes]]:
|
|
130
|
+
"""Reverse `_encode_headers`, restoring the `{bytes: [bytes]}` mapping Scrapy expects."""
|
|
131
|
+
decoded: dict[bytes, list[bytes]] = {}
|
|
132
|
+
for key, value in headers.items():
|
|
133
|
+
bytes_key = key.encode('latin-1') if isinstance(key, str) else key
|
|
134
|
+
values = value if isinstance(value, list) else [value]
|
|
135
|
+
decoded[bytes_key] = [base64.b64decode(item, validate=True) for item in values]
|
|
136
|
+
return decoded
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _b64encode_value(value: Any) -> str:
|
|
140
|
+
"""Base64-encode a single header value.
|
|
141
|
+
|
|
142
|
+
Scrapy stores header values as `bytes`; a `str` is encoded as its UTF-8 bytes. Any other type is coerced with
|
|
143
|
+
`str()` as a lenient last resort. That coercion is lossy (e.g. `5` becomes `b'5'`), but Scrapy does not produce
|
|
144
|
+
non-`bytes`/`str` header values, so it is not hit on the real path.
|
|
145
|
+
"""
|
|
146
|
+
raw = value if isinstance(value, bytes) else str(value).encode('utf-8')
|
|
147
|
+
return base64.b64encode(raw).decode('ascii')
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Derived from apify-sdk-python (src/apify/scrapy/_warnings.py),
|
|
3
|
+
# Copyright Apify Technologies s.r.o., licensed under the Apache License 2.0.
|
|
4
|
+
# Modified by TeraShift GmbH. See LICENSE-APACHE and NOTICE.
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from crawlee._utils.log import LoggerOnce
|
|
10
|
+
|
|
11
|
+
logger_once = LoggerOnce(logging.getLogger(__name__))
|
|
12
|
+
"""Process-wide deduplication: the scheduler and the graceful-stop extension share the one message below."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def warn_about_uninitialized_actor() -> None:
|
|
16
|
+
"""Warn, once per process, that the integration cannot react to the Actor run being migrated or aborted.
|
|
17
|
+
|
|
18
|
+
The scheduler and the graceful-stop extension both need an initialized Actor to listen for its events, and both
|
|
19
|
+
run without one in a plain `scrapy crawl`. One message covers them both.
|
|
20
|
+
"""
|
|
21
|
+
logger_once.log(
|
|
22
|
+
'The Actor is not initialized, so the Scrapy integration cannot react to a migration or an abort of the '
|
|
23
|
+
'Actor run; the requests Scrapy is working on when the run is interrupted stay pending in the request queue.',
|
|
24
|
+
key='uninitialized-actor',
|
|
25
|
+
level=logging.WARNING,
|
|
26
|
+
)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Derived from apify-sdk-python (src/apify/scrapy/extensions/__init__.py),
|
|
3
|
+
# Copyright Apify Technologies s.r.o., licensed under the Apache License 2.0.
|
|
4
|
+
# Modified by TeraShift GmbH. See LICENSE-APACHE and NOTICE.
|
|
5
|
+
from scrapely.scrapy.extensions._graceful_stop import ApifyGracefulStopExtension
|
|
6
|
+
from scrapely.scrapy.extensions._httpcache import ApifyCacheStorage
|
|
7
|
+
|
|
8
|
+
__all__ = ['ApifyCacheStorage', 'ApifyGracefulStopExtension']
|