scrapely-python 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.
- scrapely/__init__.py +246 -0
- scrapely/_browser_locations.py +5470 -0
- scrapely/actor.py +1836 -0
- scrapely/browser_configuration.py +146 -0
- scrapely/charging.py +1143 -0
- scrapely/charging_client.py +107 -0
- scrapely/configuration.py +375 -0
- scrapely/crypto/__init__.py +21 -0
- scrapely/crypto/_secrets.py +210 -0
- scrapely/errors.py +153 -0
- scrapely/events/__init__.py +57 -0
- scrapely/events/_types.py +194 -0
- scrapely/events/event_manager.py +292 -0
- scrapely/log.py +39 -0
- scrapely/patch_browser_pool.py +861 -0
- scrapely/pricing_types.py +172 -0
- scrapely/proxy_configuration.py +741 -0
- scrapely/py.typed +2 -0
- scrapely/request_loaders/__init__.py +5 -0
- scrapely/request_loaders/_scrapely_request_list.py +169 -0
- scrapely/storage/__init__.py +48 -0
- scrapely/storage/_file_system/__init__.py +9 -0
- scrapely/storage/_file_system/_key_value_store_client.py +155 -0
- scrapely/storage/_file_system/_storage_client.py +50 -0
- scrapely/storage/_metadata.py +138 -0
- scrapely/storage/api_client.py +350 -0
- scrapely/storage/client.py +618 -0
- scrapely/storage/patch_storage.py +257 -0
- scrapely/storage/scrapely_dataset_client.py +305 -0
- scrapely/storage/scrapely_kvs_client.py +220 -0
- scrapely/storage/scrapely_rq_client.py +496 -0
- scrapely/storage/scrapely_storage_client.py +192 -0
- scrapely/storage/smart_scrapely_storage_client.py +109 -0
- scrapely/storage/types.py +601 -0
- scrapely/storage/utils.py +38 -0
- scrapely/storage_clients/__init__.py +25 -0
- scrapely/storages/__init__.py +5 -0
- scrapely/types.py +269 -0
- scrapely/webhook.py +82 -0
- scrapely_python-0.1.0.dist-info/METADATA +83 -0
- scrapely_python-0.1.0.dist-info/RECORD +43 -0
- scrapely_python-0.1.0.dist-info/WHEEL +5 -0
- scrapely_python-0.1.0.dist-info/top_level.txt +1 -0
scrapely/py.typed
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""ScrapelyRequestList - a request list that parses the standard Scrapely/Apify requestListSources input format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from itertools import chain
|
|
9
|
+
from typing import Annotated, Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
|
|
12
|
+
from pydantic.alias_generators import to_camel
|
|
13
|
+
|
|
14
|
+
from crawlee._types import HttpMethod, JsonSerializable
|
|
15
|
+
from crawlee.http_clients import HttpClient, ImpitHttpClient
|
|
16
|
+
from crawlee import Request
|
|
17
|
+
from crawlee.request_loaders import RequestList
|
|
18
|
+
|
|
19
|
+
# Regex for extracting URLs from remote text files (matches URLs without commas)
|
|
20
|
+
URL_NO_COMMAS_REGEX = re.compile(
|
|
21
|
+
r'https?:\/\/(www\.)?([^\W_]|[^\W_][-\w0-9@:%._+~#=]{0,254}[^\W_])\.[a-z]{2,63}(:\d{1,5})?(\/[-\w@:%+.~#?&/=()]*)?'
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _RequestDetails(BaseModel):
|
|
26
|
+
"""Common request details that can be applied to all requests from a source entry."""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
|
|
29
|
+
|
|
30
|
+
method: HttpMethod = 'GET'
|
|
31
|
+
payload: str = ''
|
|
32
|
+
headers: Annotated[dict[str, str], Field(default_factory=dict)]
|
|
33
|
+
user_data: Annotated[dict[str, JsonSerializable], Field(default_factory=dict)]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _RequestsFromUrlInput(_RequestDetails):
|
|
37
|
+
"""Input entry for a remote URL list (requestsFromUrl)."""
|
|
38
|
+
|
|
39
|
+
requests_from_url: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _SimpleUrlInput(_RequestDetails):
|
|
43
|
+
"""Input entry for a direct URL."""
|
|
44
|
+
|
|
45
|
+
url: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Pydantic type adapter for parsing the requestListSources input format
|
|
49
|
+
url_input_adapter = TypeAdapter(list[_RequestsFromUrlInput | _SimpleUrlInput])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ScrapelyRequestList(RequestList):
|
|
53
|
+
"""A request list that can be constructed from the standard Scrapely/Apify ``requestListSources`` input format.
|
|
54
|
+
|
|
55
|
+
This extends the Crawlee ``RequestList`` with the ability to parse the request list sources input commonly
|
|
56
|
+
used in Scrapely Actors. It supports two kinds of entries:
|
|
57
|
+
|
|
58
|
+
- **Direct URLs** - entries with a ``url`` key are converted to requests directly.
|
|
59
|
+
- **Remote URL lists** - entries with a ``requestsFromUrl`` key point to a remote resource (e.g. a plain-text
|
|
60
|
+
file). The resource is fetched and all URLs found in the response body are extracted and converted to requests.
|
|
61
|
+
|
|
62
|
+
Both kinds of entries can optionally specify ``method``, ``payload``, ``headers``, and ``userData`` fields that
|
|
63
|
+
will be applied to every request created from that entry.
|
|
64
|
+
|
|
65
|
+
Usage:
|
|
66
|
+
```python
|
|
67
|
+
from scrapely import Actor
|
|
68
|
+
from scrapely.request_loaders import ScrapelyRequestList
|
|
69
|
+
|
|
70
|
+
async with Actor:
|
|
71
|
+
actor_input = await Actor.get_input() or {}
|
|
72
|
+
request_list = await ScrapelyRequestList.open(
|
|
73
|
+
request_list_sources_input=actor_input.get('requestListSources', []),
|
|
74
|
+
)
|
|
75
|
+
```
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
async def open(
|
|
80
|
+
cls,
|
|
81
|
+
*,
|
|
82
|
+
name: str | None = None,
|
|
83
|
+
request_list_sources_input: list[dict[str, Any]] | None = None,
|
|
84
|
+
http_client: HttpClient | None = None,
|
|
85
|
+
) -> ScrapelyRequestList:
|
|
86
|
+
"""Create a new ``ScrapelyRequestList`` from the standard Scrapely request list sources input.
|
|
87
|
+
|
|
88
|
+
Each entry in ``request_list_sources_input`` is a dict with either a ``url`` key (for a direct URL) or
|
|
89
|
+
a ``requestsFromUrl`` key (for a remote resource whose response body is scanned for URLs). Optional keys
|
|
90
|
+
``method``, ``payload``, ``headers``, and ``userData`` are applied to every request produced from that entry.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
name: An optional name for the request list, used for state persistence.
|
|
94
|
+
request_list_sources_input: A list of request source dicts in the standard Scrapely format. Each dict
|
|
95
|
+
must contain either a ``url`` key or a ``requestsFromUrl`` key. If ``None`` or empty, an empty
|
|
96
|
+
request list is returned.
|
|
97
|
+
http_client: HTTP client used to fetch remote URL lists (entries with ``requestsFromUrl``). Defaults to
|
|
98
|
+
``ImpitHttpClient`` if not provided.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
A new ``ScrapelyRequestList`` populated with the resolved requests.
|
|
102
|
+
"""
|
|
103
|
+
request_list_sources_input = request_list_sources_input or []
|
|
104
|
+
|
|
105
|
+
if not http_client:
|
|
106
|
+
http_client = ImpitHttpClient()
|
|
107
|
+
|
|
108
|
+
url_inputs = url_input_adapter.validate_python(request_list_sources_input)
|
|
109
|
+
|
|
110
|
+
simple_url_inputs = [url_input for url_input in url_inputs if isinstance(url_input, _SimpleUrlInput)]
|
|
111
|
+
remote_url_inputs = [url_input for url_input in url_inputs if isinstance(url_input, _RequestsFromUrlInput)]
|
|
112
|
+
|
|
113
|
+
simple_url_requests = cls._create_requests_from_input(simple_url_inputs)
|
|
114
|
+
remote_url_requests = await cls._fetch_requests_from_url(remote_url_inputs, http_client)
|
|
115
|
+
|
|
116
|
+
return ScrapelyRequestList(name=name, requests=simple_url_requests + remote_url_requests)
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
async def _fetch_requests_from_url(
|
|
120
|
+
cls,
|
|
121
|
+
remote_url_requests_inputs: list[_RequestsFromUrlInput],
|
|
122
|
+
http_client: HttpClient,
|
|
123
|
+
) -> list[Request]:
|
|
124
|
+
"""Fetch all remote URL sources concurrently and return the extracted requests.
|
|
125
|
+
|
|
126
|
+
For each entry, a GET request is sent to the ``requests_from_url`` URL. All URLs matching
|
|
127
|
+
``URL_NO_COMMAS_REGEX`` are extracted from the response body and turned into ``Request`` objects,
|
|
128
|
+
inheriting ``method``, ``payload``, ``headers``, and ``user_data`` from the source entry.
|
|
129
|
+
"""
|
|
130
|
+
async with asyncio.TaskGroup() as tg:
|
|
131
|
+
tasks = [
|
|
132
|
+
tg.create_task(cls._process_remote_url(request_input, http_client))
|
|
133
|
+
for request_input in remote_url_requests_inputs
|
|
134
|
+
]
|
|
135
|
+
return list(chain.from_iterable(task.result() for task in tasks))
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _create_requests_from_input(simple_url_inputs: list[_SimpleUrlInput]) -> list[Request]:
|
|
139
|
+
"""Create ``Request`` objects from simple URL inputs."""
|
|
140
|
+
return [
|
|
141
|
+
Request.from_url(
|
|
142
|
+
method=request_input.method,
|
|
143
|
+
url=request_input.url,
|
|
144
|
+
payload=request_input.payload.encode('utf-8'),
|
|
145
|
+
headers=request_input.headers,
|
|
146
|
+
user_data=request_input.user_data,
|
|
147
|
+
)
|
|
148
|
+
for request_input in simple_url_inputs
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
async def _process_remote_url(request_input: _RequestsFromUrlInput, http_client: HttpClient) -> list[Request]:
|
|
153
|
+
"""Fetch a remote URL and extract links from the response body."""
|
|
154
|
+
http_response = await http_client.send_request(method='GET', url=request_input.requests_from_url)
|
|
155
|
+
response_body = await http_response.read()
|
|
156
|
+
matches = re.finditer(URL_NO_COMMAS_REGEX, response_body.decode('utf-8', errors='replace'))
|
|
157
|
+
|
|
158
|
+
return [
|
|
159
|
+
Request.from_url(
|
|
160
|
+
url=match.group(0),
|
|
161
|
+
method=request_input.method,
|
|
162
|
+
payload=request_input.payload.encode('utf-8'),
|
|
163
|
+
headers=request_input.headers,
|
|
164
|
+
# Deep-copy so Request.from_url (which writes __crawlee into the dict) cannot corrupt
|
|
165
|
+
# the shared input, and nested JSON values are not aliased across the requests.
|
|
166
|
+
user_data=deepcopy(request_input.user_data),
|
|
167
|
+
)
|
|
168
|
+
for match in matches
|
|
169
|
+
]
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Scrapely storage: Crawlee StorageClient backends + HTTP helpers."""
|
|
2
|
+
|
|
3
|
+
from crawlee import Request
|
|
4
|
+
from crawlee.storage_clients import MemoryStorageClient, StorageClient
|
|
5
|
+
from crawlee.storages import Dataset, KeyValueStore, RequestQueue
|
|
6
|
+
|
|
7
|
+
from .api_client import ApiClient, ApiClientError
|
|
8
|
+
from .client import ApiStorageClient
|
|
9
|
+
from ._file_system import ScrapelyFileSystemStorageClient
|
|
10
|
+
from .patch_storage import (
|
|
11
|
+
disconnect_storage,
|
|
12
|
+
get_api_storage_factory,
|
|
13
|
+
get_auto_saved_value,
|
|
14
|
+
get_smart_storage_client,
|
|
15
|
+
is_api_storage_configured,
|
|
16
|
+
persist_autosaved_values,
|
|
17
|
+
set_api_storage,
|
|
18
|
+
set_storage_client,
|
|
19
|
+
)
|
|
20
|
+
from .scrapely_storage_client import ScrapelyStorageClient
|
|
21
|
+
from .smart_scrapely_storage_client import SmartScrapelyStorageClient
|
|
22
|
+
|
|
23
|
+
# Apify-style public alias
|
|
24
|
+
FileSystemStorageClient = ScrapelyFileSystemStorageClient
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
'ApiClient',
|
|
28
|
+
'ApiClientError',
|
|
29
|
+
'ApiStorageClient',
|
|
30
|
+
'ScrapelyStorageClient',
|
|
31
|
+
'SmartScrapelyStorageClient',
|
|
32
|
+
'FileSystemStorageClient',
|
|
33
|
+
'ScrapelyFileSystemStorageClient',
|
|
34
|
+
'MemoryStorageClient',
|
|
35
|
+
'StorageClient',
|
|
36
|
+
'set_api_storage',
|
|
37
|
+
'set_storage_client',
|
|
38
|
+
'get_api_storage_factory',
|
|
39
|
+
'get_smart_storage_client',
|
|
40
|
+
'is_api_storage_configured',
|
|
41
|
+
'disconnect_storage',
|
|
42
|
+
'get_auto_saved_value',
|
|
43
|
+
'persist_autosaved_values',
|
|
44
|
+
'Dataset',
|
|
45
|
+
'KeyValueStore',
|
|
46
|
+
'RequestQueue',
|
|
47
|
+
'Request',
|
|
48
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Scrapely file-system storage clients (Apify FileSystemStorageClient shape)."""
|
|
2
|
+
|
|
3
|
+
from ._key_value_store_client import ScrapelyFileSystemKeyValueStoreClient
|
|
4
|
+
from ._storage_client import ScrapelyFileSystemStorageClient
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
'ScrapelyFileSystemKeyValueStoreClient',
|
|
8
|
+
'ScrapelyFileSystemStorageClient',
|
|
9
|
+
]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""File-system KVS client that preserves INPUT on purge (Apify parity)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from itertools import chain
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
from typing_extensions import Self, override
|
|
13
|
+
|
|
14
|
+
from crawlee._consts import METADATA_FILENAME
|
|
15
|
+
from crawlee._utils.file import atomic_write, infer_mime_type, json_dumps
|
|
16
|
+
from crawlee.configuration import Configuration as CrawleeConfiguration
|
|
17
|
+
from crawlee.storage_clients._file_system import FileSystemKeyValueStoreClient
|
|
18
|
+
from crawlee.storage_clients.models import KeyValueStoreMetadata, KeyValueStoreRecord, KeyValueStoreRecordMetadata
|
|
19
|
+
|
|
20
|
+
from scrapely.configuration import Configuration as ScrapelyConfiguration
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ScrapelyFileSystemKeyValueStoreClient(FileSystemKeyValueStoreClient):
|
|
26
|
+
"""File-system KVS that keeps metadata and INPUT when purged."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
metadata: KeyValueStoreMetadata,
|
|
32
|
+
path_to_kvs: Path,
|
|
33
|
+
lock: asyncio.Lock,
|
|
34
|
+
) -> None:
|
|
35
|
+
super().__init__(metadata=metadata, path_to_kvs=path_to_kvs, lock=lock)
|
|
36
|
+
global_configuration = ScrapelyConfiguration.get_global_config()
|
|
37
|
+
self._input_key = global_configuration.input_key
|
|
38
|
+
self._input_key_filename = global_configuration.input_key
|
|
39
|
+
|
|
40
|
+
@override
|
|
41
|
+
@classmethod
|
|
42
|
+
async def open(
|
|
43
|
+
cls,
|
|
44
|
+
*,
|
|
45
|
+
id: Optional[str],
|
|
46
|
+
name: Optional[str],
|
|
47
|
+
alias: Optional[str],
|
|
48
|
+
configuration: CrawleeConfiguration,
|
|
49
|
+
) -> Self:
|
|
50
|
+
client = await super().open(id=id, name=name, alias=alias, configuration=configuration)
|
|
51
|
+
|
|
52
|
+
scrapely_config = ScrapelyConfiguration.get_global_config()
|
|
53
|
+
client._input_key = scrapely_config.input_key # noqa: SLF001
|
|
54
|
+
input_key_filename = cls._get_input_key_file_name(
|
|
55
|
+
path_to_kvs=client.path_to_kvs,
|
|
56
|
+
input_key=scrapely_config.input_key,
|
|
57
|
+
)
|
|
58
|
+
client._input_key_filename = input_key_filename # noqa: SLF001
|
|
59
|
+
input_file_path = client.path_to_kvs / input_key_filename
|
|
60
|
+
input_file_metadata_path = client.path_to_kvs / f'{input_file_path.name}.{METADATA_FILENAME}'
|
|
61
|
+
if input_file_path.exists() and not input_file_metadata_path.exists():
|
|
62
|
+
await cls._create_missing_metadata_for_input_file(
|
|
63
|
+
key=scrapely_config.input_key,
|
|
64
|
+
record_path=input_file_path,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return client
|
|
68
|
+
|
|
69
|
+
@override
|
|
70
|
+
async def purge(self) -> None:
|
|
71
|
+
"""Delete all files except store metadata and INPUT (+ its metadata sidecar)."""
|
|
72
|
+
async with self._lock:
|
|
73
|
+
files_to_keep = {
|
|
74
|
+
self._input_key_filename,
|
|
75
|
+
f'{self._input_key_filename}.{METADATA_FILENAME}',
|
|
76
|
+
METADATA_FILENAME,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async with asyncio.TaskGroup() as tg:
|
|
80
|
+
for file_path in self.path_to_kvs.glob('*'):
|
|
81
|
+
if file_path.name in files_to_keep:
|
|
82
|
+
continue
|
|
83
|
+
if file_path.is_file():
|
|
84
|
+
tg.create_task(asyncio.to_thread(file_path.unlink, missing_ok=True))
|
|
85
|
+
|
|
86
|
+
await self._update_metadata(
|
|
87
|
+
update_accessed_at=True,
|
|
88
|
+
update_modified_at=True,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@override
|
|
92
|
+
async def get_value(self, *, key: str) -> Optional[KeyValueStoreRecord]:
|
|
93
|
+
return await super().get_value(key=self._resolve_input_key(key))
|
|
94
|
+
|
|
95
|
+
@override
|
|
96
|
+
async def set_value(self, *, key: str, value: Any, content_type: Optional[str] = None) -> None:
|
|
97
|
+
await super().set_value(key=self._resolve_input_key(key), value=value, content_type=content_type)
|
|
98
|
+
|
|
99
|
+
@override
|
|
100
|
+
async def record_exists(self, *, key: str) -> bool:
|
|
101
|
+
return await super().record_exists(key=self._resolve_input_key(key))
|
|
102
|
+
|
|
103
|
+
@override
|
|
104
|
+
async def delete_value(self, *, key: str) -> None:
|
|
105
|
+
await super().delete_value(key=self._resolve_input_key(key))
|
|
106
|
+
|
|
107
|
+
@override
|
|
108
|
+
async def get_public_url(self, *, key: str) -> str:
|
|
109
|
+
return await super().get_public_url(key=self._resolve_input_key(key))
|
|
110
|
+
|
|
111
|
+
def _resolve_input_key(self, key: str) -> str:
|
|
112
|
+
return self._input_key_filename if key == self._input_key else key
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
async def _create_missing_metadata_for_input_file(key: str, record_path: Path) -> None:
|
|
116
|
+
try:
|
|
117
|
+
content = await asyncio.to_thread(record_path.read_bytes)
|
|
118
|
+
except FileNotFoundError:
|
|
119
|
+
logger.warning('Input file disappeared from path: "%s"', record_path)
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
size = len(content)
|
|
123
|
+
if record_path.suffix == '.json':
|
|
124
|
+
value: Any = json.loads(content.decode('utf-8'))
|
|
125
|
+
elif record_path.suffix == '.txt':
|
|
126
|
+
value = content.decode('utf-8')
|
|
127
|
+
elif record_path.suffix == '':
|
|
128
|
+
try:
|
|
129
|
+
value = json.loads(content.decode('utf-8'))
|
|
130
|
+
except json.JSONDecodeError:
|
|
131
|
+
value = content
|
|
132
|
+
else:
|
|
133
|
+
value = content
|
|
134
|
+
|
|
135
|
+
content_type = infer_mime_type(value)
|
|
136
|
+
record_metadata = KeyValueStoreRecordMetadata(key=key, content_type=content_type, size=size)
|
|
137
|
+
record_metadata_filepath = record_path.with_name(f'{record_path.name}.{METADATA_FILENAME}')
|
|
138
|
+
record_metadata_content = await json_dumps(record_metadata.model_dump())
|
|
139
|
+
await atomic_write(record_metadata_filepath, record_metadata_content)
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _get_input_key_file_name(path_to_kvs: Path, input_key: str) -> str:
|
|
143
|
+
found_input_files = set()
|
|
144
|
+
for file_path in chain(path_to_kvs.glob(f'{input_key}.*'), path_to_kvs.glob(f'{input_key}')):
|
|
145
|
+
if str(file_path).endswith(METADATA_FILENAME):
|
|
146
|
+
continue
|
|
147
|
+
found_input_files.add(file_path.name)
|
|
148
|
+
|
|
149
|
+
if len(found_input_files) > 1:
|
|
150
|
+
raise RuntimeError(f'Only one input file is allowed. Following input files found: {found_input_files}')
|
|
151
|
+
|
|
152
|
+
if len(found_input_files) == 1:
|
|
153
|
+
return found_input_files.pop()
|
|
154
|
+
|
|
155
|
+
return input_key
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Scrapely file-system StorageClient (ApifyFileSystemStorageClient shape)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Optional
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
from crawlee.configuration import Configuration
|
|
10
|
+
from crawlee.storage_clients import FileSystemStorageClient
|
|
11
|
+
|
|
12
|
+
from ._key_value_store_client import ScrapelyFileSystemKeyValueStoreClient
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Hashable
|
|
16
|
+
|
|
17
|
+
from crawlee.storage_clients._file_system import FileSystemKeyValueStoreClient
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ScrapelyFileSystemStorageClient(FileSystemStorageClient):
|
|
21
|
+
"""File-system storage client with Scrapely KVS purge that preserves INPUT.
|
|
22
|
+
|
|
23
|
+
The only difference from Crawlee's ``FileSystemStorageClient`` is that it uses
|
|
24
|
+
``ScrapelyFileSystemKeyValueStoreClient`` for key-value stores, which overrides
|
|
25
|
+
``purge`` to keep the metadata file and the INPUT file.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
@override
|
|
29
|
+
def get_storage_client_cache_key(self, configuration: Configuration) -> Hashable:
|
|
30
|
+
# Same cache key as FileSystemStorageClient to avoid double-purge.
|
|
31
|
+
return FileSystemStorageClient().get_storage_client_cache_key(configuration)
|
|
32
|
+
|
|
33
|
+
@override
|
|
34
|
+
async def create_kvs_client(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
id: Optional[str] = None,
|
|
38
|
+
name: Optional[str] = None,
|
|
39
|
+
alias: Optional[str] = None,
|
|
40
|
+
configuration: Optional[Configuration] = None,
|
|
41
|
+
) -> FileSystemKeyValueStoreClient:
|
|
42
|
+
configuration = configuration or Configuration.get_global_configuration()
|
|
43
|
+
client = await ScrapelyFileSystemKeyValueStoreClient.open(
|
|
44
|
+
id=id,
|
|
45
|
+
name=name,
|
|
46
|
+
alias=alias,
|
|
47
|
+
configuration=configuration,
|
|
48
|
+
)
|
|
49
|
+
await self._purge_if_needed(client, configuration)
|
|
50
|
+
return client
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""HTTP helpers shared by Scrapely Crawlee storage clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Any, Literal, Mapping, Optional, cast
|
|
10
|
+
|
|
11
|
+
from crawlee.storage_clients.models import (
|
|
12
|
+
DatasetMetadata,
|
|
13
|
+
KeyValueStoreMetadata,
|
|
14
|
+
RequestQueueMetadata,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
StorageType = Literal['datasets', 'keyValueStores', 'requestQueues']
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_api_datetime(value: Any) -> datetime:
|
|
23
|
+
if isinstance(value, datetime):
|
|
24
|
+
if value.tzinfo is None:
|
|
25
|
+
return value.replace(tzinfo=timezone.utc)
|
|
26
|
+
return value
|
|
27
|
+
if isinstance(value, str) and value:
|
|
28
|
+
try:
|
|
29
|
+
parsed = datetime.fromisoformat(value.replace('Z', '+00:00'))
|
|
30
|
+
if parsed.tzinfo is None:
|
|
31
|
+
return parsed.replace(tzinfo=timezone.utc)
|
|
32
|
+
return parsed
|
|
33
|
+
except ValueError:
|
|
34
|
+
pass
|
|
35
|
+
return datetime.now(timezone.utc)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def to_dataset_metadata(data: Mapping[str, Any], fallback_id: str = '') -> DatasetMetadata:
|
|
39
|
+
return DatasetMetadata(
|
|
40
|
+
id=str(data.get('id') or fallback_id),
|
|
41
|
+
name=data.get('name'),
|
|
42
|
+
created_at=parse_api_datetime(data.get('createdAt')),
|
|
43
|
+
accessed_at=parse_api_datetime(data.get('accessedAt')),
|
|
44
|
+
modified_at=parse_api_datetime(data.get('modifiedAt')),
|
|
45
|
+
item_count=int(data.get('itemCount') or 0),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def to_kvs_metadata(data: Mapping[str, Any], fallback_id: str = '') -> KeyValueStoreMetadata:
|
|
50
|
+
return KeyValueStoreMetadata(
|
|
51
|
+
id=str(data.get('id') or fallback_id),
|
|
52
|
+
name=data.get('name'),
|
|
53
|
+
created_at=parse_api_datetime(data.get('createdAt')),
|
|
54
|
+
accessed_at=parse_api_datetime(data.get('accessedAt')),
|
|
55
|
+
modified_at=parse_api_datetime(data.get('modifiedAt')),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def to_rq_metadata(data: Mapping[str, Any], fallback_id: str = '') -> RequestQueueMetadata:
|
|
60
|
+
return RequestQueueMetadata(
|
|
61
|
+
id=str(data.get('id') or fallback_id),
|
|
62
|
+
name=data.get('name'),
|
|
63
|
+
created_at=parse_api_datetime(data.get('createdAt')),
|
|
64
|
+
accessed_at=parse_api_datetime(data.get('accessedAt')),
|
|
65
|
+
modified_at=parse_api_datetime(data.get('modifiedAt')),
|
|
66
|
+
had_multiple_clients=bool(data.get('hadMultipleClients') or False),
|
|
67
|
+
handled_request_count=int(data.get('handledRequestCount') or 0),
|
|
68
|
+
pending_request_count=int(data.get('pendingRequestCount') or 0),
|
|
69
|
+
total_request_count=int(data.get('totalRequestCount') or 0),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def parse_actor_storages_json(raw: Optional[str]) -> Optional[dict[str, Any]]:
|
|
74
|
+
"""Parse ``ACTOR_STORAGES_JSON`` (plain JSON or base64-encoded JSON)."""
|
|
75
|
+
if not raw:
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
return cast(dict[str, Any], json.loads(raw))
|
|
79
|
+
except json.JSONDecodeError:
|
|
80
|
+
pass
|
|
81
|
+
try:
|
|
82
|
+
decoded = base64.b64decode(raw).decode('utf-8')
|
|
83
|
+
return cast(dict[str, Any], json.loads(decoded))
|
|
84
|
+
except Exception:
|
|
85
|
+
logger.debug('Failed to parse ACTOR_STORAGES_JSON', exc_info=True)
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def resolve_alias_from_storages_json(
|
|
90
|
+
alias: str,
|
|
91
|
+
storage_type: StorageType,
|
|
92
|
+
actor_storages_json: Optional[str] = None,
|
|
93
|
+
) -> Optional[str]:
|
|
94
|
+
"""Resolve an alias to a storage id via ``ACTOR_STORAGES_JSON`` (TS parity).
|
|
95
|
+
|
|
96
|
+
Returns the resolved id, or ``None`` if not found (caller should treat alias as name).
|
|
97
|
+
"""
|
|
98
|
+
parsed = parse_actor_storages_json(actor_storages_json)
|
|
99
|
+
if not parsed:
|
|
100
|
+
return None
|
|
101
|
+
bucket = parsed.get(storage_type)
|
|
102
|
+
if isinstance(bucket, dict):
|
|
103
|
+
value = bucket.get(alias)
|
|
104
|
+
if isinstance(value, str) and value:
|
|
105
|
+
return value
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def resolve_storage_id(
|
|
110
|
+
*,
|
|
111
|
+
id: Optional[str],
|
|
112
|
+
name: Optional[str],
|
|
113
|
+
alias: Optional[str],
|
|
114
|
+
default_id: Optional[str],
|
|
115
|
+
storage_type: Optional[StorageType] = None,
|
|
116
|
+
actor_storages_json: Optional[str] = None,
|
|
117
|
+
) -> tuple[Optional[str], Optional[str]]:
|
|
118
|
+
"""Return (id, name) for get-or-create.
|
|
119
|
+
|
|
120
|
+
Alias is resolved via ``ACTOR_STORAGES_JSON`` when possible; otherwise treated as a name
|
|
121
|
+
(same as TypeScript Actor outside the platform).
|
|
122
|
+
"""
|
|
123
|
+
provided = [v for v in (id, name, alias) if v is not None]
|
|
124
|
+
if len(provided) > 1:
|
|
125
|
+
raise ValueError('Only one of id, name, or alias may be provided')
|
|
126
|
+
if alias is not None:
|
|
127
|
+
if storage_type is not None:
|
|
128
|
+
resolved = resolve_alias_from_storages_json(
|
|
129
|
+
alias,
|
|
130
|
+
storage_type,
|
|
131
|
+
actor_storages_json,
|
|
132
|
+
)
|
|
133
|
+
if resolved is not None:
|
|
134
|
+
return resolved, None
|
|
135
|
+
return None, alias
|
|
136
|
+
if id is not None or name is not None:
|
|
137
|
+
return id, name
|
|
138
|
+
return default_id, None
|