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.
Files changed (43) hide show
  1. scrapely/__init__.py +246 -0
  2. scrapely/_browser_locations.py +5470 -0
  3. scrapely/actor.py +1836 -0
  4. scrapely/browser_configuration.py +146 -0
  5. scrapely/charging.py +1143 -0
  6. scrapely/charging_client.py +107 -0
  7. scrapely/configuration.py +375 -0
  8. scrapely/crypto/__init__.py +21 -0
  9. scrapely/crypto/_secrets.py +210 -0
  10. scrapely/errors.py +153 -0
  11. scrapely/events/__init__.py +57 -0
  12. scrapely/events/_types.py +194 -0
  13. scrapely/events/event_manager.py +292 -0
  14. scrapely/log.py +39 -0
  15. scrapely/patch_browser_pool.py +861 -0
  16. scrapely/pricing_types.py +172 -0
  17. scrapely/proxy_configuration.py +741 -0
  18. scrapely/py.typed +2 -0
  19. scrapely/request_loaders/__init__.py +5 -0
  20. scrapely/request_loaders/_scrapely_request_list.py +169 -0
  21. scrapely/storage/__init__.py +48 -0
  22. scrapely/storage/_file_system/__init__.py +9 -0
  23. scrapely/storage/_file_system/_key_value_store_client.py +155 -0
  24. scrapely/storage/_file_system/_storage_client.py +50 -0
  25. scrapely/storage/_metadata.py +138 -0
  26. scrapely/storage/api_client.py +350 -0
  27. scrapely/storage/client.py +618 -0
  28. scrapely/storage/patch_storage.py +257 -0
  29. scrapely/storage/scrapely_dataset_client.py +305 -0
  30. scrapely/storage/scrapely_kvs_client.py +220 -0
  31. scrapely/storage/scrapely_rq_client.py +496 -0
  32. scrapely/storage/scrapely_storage_client.py +192 -0
  33. scrapely/storage/smart_scrapely_storage_client.py +109 -0
  34. scrapely/storage/types.py +601 -0
  35. scrapely/storage/utils.py +38 -0
  36. scrapely/storage_clients/__init__.py +25 -0
  37. scrapely/storages/__init__.py +5 -0
  38. scrapely/types.py +269 -0
  39. scrapely/webhook.py +82 -0
  40. scrapely_python-0.1.0.dist-info/METADATA +83 -0
  41. scrapely_python-0.1.0.dist-info/RECORD +43 -0
  42. scrapely_python-0.1.0.dist-info/WHEEL +5 -0
  43. scrapely_python-0.1.0.dist-info/top_level.txt +1 -0
scrapely/py.typed ADDED
@@ -0,0 +1,2 @@
1
+ # Marker file for PEP 561.
2
+ # The presence of this file indicates that the package supports type checking.
@@ -0,0 +1,5 @@
1
+ """Scrapely request loaders - extends Crawlee's request loaders with Scrapely-specific functionality."""
2
+
3
+ from ._scrapely_request_list import ScrapelyRequestList
4
+
5
+ __all__ = ['ScrapelyRequestList']
@@ -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