gofetch-client 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.
gofetch/__init__.py ADDED
@@ -0,0 +1,94 @@
1
+ """
2
+ GoFetch Python Client - Social Media Scraping API
3
+
4
+ A drop-in replacement for apify-client that uses the GoFetch.io API.
5
+
6
+ Usage:
7
+ from gofetch import GoFetchClient
8
+
9
+ client = GoFetchClient(api_key="sk_scr_...")
10
+
11
+ # Sync execution (blocks until complete)
12
+ actor = client.actor("instagram")
13
+ run = actor.call(run_input={"directUrls": ["https://instagram.com/nike"]})
14
+
15
+ # Fetch results
16
+ dataset = client.dataset(run["defaultDatasetId"])
17
+ items = list(dataset.iterate_items())
18
+
19
+ # Async execution (returns immediately, uses webhooks)
20
+ run = actor.start(
21
+ run_input={"directUrls": ["https://instagram.com/nike"]},
22
+ webhooks=[{"request_url": "https://...", "event_types": ["ACTOR.RUN.SUCCEEDED"]}]
23
+ )
24
+ """
25
+
26
+ from gofetch.actor import ActorClient, AsyncActorClient
27
+ from gofetch.client import AsyncGoFetchClient, GoFetchClient
28
+ from gofetch.dataset import AsyncDatasetClient, DatasetClient
29
+ from gofetch.exceptions import (
30
+ APIError,
31
+ AuthenticationError,
32
+ GoFetchError,
33
+ JobError,
34
+ RateLimitError,
35
+ TimeoutError,
36
+ ValidationError,
37
+ )
38
+ from gofetch.log import AsyncLogClient, LogClient
39
+ from gofetch.run import AsyncRunClient, RunClient
40
+ from gofetch.types import (
41
+ JobStatus,
42
+ RunStatus,
43
+ ScraperType,
44
+ )
45
+ from gofetch.webhook import (
46
+ WebhookEventType,
47
+ generate_webhook_config,
48
+ transform_webhook_payload,
49
+ verify_webhook_signature,
50
+ )
51
+ from gofetch.webhook_client import (
52
+ AsyncWebhookClient,
53
+ AsyncWebhookCollectionClient,
54
+ WebhookClient,
55
+ WebhookCollectionClient,
56
+ )
57
+
58
+ # Apify compatibility alias
59
+ ApifyClient = GoFetchClient
60
+
61
+ __version__ = "0.1.0"
62
+
63
+ __all__ = [
64
+ "APIError",
65
+ "ActorClient",
66
+ "ApifyClient",
67
+ "AsyncActorClient",
68
+ "AsyncDatasetClient",
69
+ "AsyncGoFetchClient",
70
+ "AsyncLogClient",
71
+ "AsyncRunClient",
72
+ "AsyncWebhookClient",
73
+ "AsyncWebhookCollectionClient",
74
+ "AuthenticationError",
75
+ "DatasetClient",
76
+ "GoFetchClient",
77
+ "GoFetchError",
78
+ "JobError",
79
+ "JobStatus",
80
+ "LogClient",
81
+ "RateLimitError",
82
+ "RunClient",
83
+ "RunStatus",
84
+ "ScraperType",
85
+ "TimeoutError",
86
+ "ValidationError",
87
+ "WebhookClient",
88
+ "WebhookCollectionClient",
89
+ "WebhookEventType",
90
+ "__version__",
91
+ "generate_webhook_config",
92
+ "transform_webhook_payload",
93
+ "verify_webhook_signature",
94
+ ]
gofetch/actor.py ADDED
@@ -0,0 +1,312 @@
1
+ """
2
+ Actor client for GoFetch API.
3
+
4
+ Provides Apify-compatible interface for running scraper jobs.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import time
11
+ import warnings
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from gofetch.constants import (
15
+ DEFAULT_POLL_INTERVAL,
16
+ GOFETCH_TO_APIFY_STATUS,
17
+ MAX_POLL_INTERVAL,
18
+ POLL_BACKOFF_FACTOR,
19
+ )
20
+ from gofetch.webhook import APIFY_TO_GOFETCH_EVENTS
21
+
22
+ if TYPE_CHECKING:
23
+ from gofetch.http import AsyncHTTPClient, HTTPClient
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ TERMINAL_STATUSES = frozenset({"completed", "failed", "timed_out", "cancelled"})
28
+
29
+
30
+ def _is_terminal(status: str) -> bool:
31
+ return status in TERMINAL_STATUSES
32
+
33
+
34
+ def _next_poll_interval(current: float) -> float:
35
+ return min(current * POLL_BACKOFF_FACTOR, MAX_POLL_INTERVAL)
36
+
37
+
38
+ def _format_job_as_apify_run(
39
+ job: dict[str, Any],
40
+ scraper_type: str | None = None,
41
+ extra_fields: dict[str, Any] | None = None,
42
+ ) -> dict[str, Any]:
43
+ """Convert GoFetch job dict to Apify run format.
44
+
45
+ IMPORTANT: Any extra fields on the job dict (e.g., scraper_metadata)
46
+ are preserved on the returned run dict.
47
+ """
48
+ scraper_type = scraper_type or job.get("scraper_type", "unknown")
49
+ status = GOFETCH_TO_APIFY_STATUS.get(job.get("status", ""), "RUNNING")
50
+
51
+ run: dict[str, Any] = {
52
+ "id": job["id"],
53
+ "actId": f"gofetch/{scraper_type}",
54
+ "status": status,
55
+ "defaultDatasetId": job["id"],
56
+ "startedAt": job.get("started_at"),
57
+ "finishedAt": job.get("completed_at"),
58
+ "buildId": None,
59
+ "buildNumber": None,
60
+ "exitCode": 0 if status == "SUCCEEDED" else None,
61
+ "defaultKeyValueStoreId": None,
62
+ "defaultRequestQueueId": None,
63
+ "_gofetch_job": job,
64
+ }
65
+ if extra_fields:
66
+ run.update(extra_fields)
67
+ return run
68
+
69
+
70
+ def _translate_webhooks(webhooks: list[dict[str, Any]]) -> list[dict[str, Any]]:
71
+ """Translate Apify-format webhooks to GoFetch format.
72
+
73
+ Apify format:
74
+ {"request_url": "https://...", "event_types": ["ACTOR.RUN.SUCCEEDED"]}
75
+ or camelCase:
76
+ {"requestUrl": "https://...", "eventTypes": ["ACTOR.RUN.SUCCEEDED"]}
77
+
78
+ GoFetch format:
79
+ {"url": "https://...", "events": ["job.completed"]}
80
+ """
81
+ translated = []
82
+ for wh in webhooks:
83
+ url = wh.get("request_url") or wh.get("requestUrl")
84
+ event_types = wh.get("event_types") or wh.get("eventTypes", [])
85
+ translated.append({
86
+ "url": url,
87
+ "events": [
88
+ APIFY_TO_GOFETCH_EVENTS.get(et, et)
89
+ for et in event_types
90
+ ],
91
+ })
92
+ return translated
93
+
94
+
95
+ class ActorClient:
96
+ """
97
+ Actor client that wraps GoFetch job API with Apify-compatible interface.
98
+
99
+ Provides the same methods as Apify's ActorClient:
100
+ - call() for synchronous execution
101
+ - start() for asynchronous execution with webhooks
102
+ """
103
+
104
+ def __init__(
105
+ self,
106
+ http: HTTPClient,
107
+ scraper_type: str,
108
+ ) -> None:
109
+ self._http = http
110
+ self._scraper_type = scraper_type
111
+
112
+ def call(
113
+ self,
114
+ run_input: dict[str, Any],
115
+ *,
116
+ wait_secs: int | None = None,
117
+ timeout_secs: int | None = None,
118
+ memory_mbytes: int | None = None,
119
+ build: str | None = None,
120
+ webhooks: list[dict[str, Any]] | None = None,
121
+ ) -> dict[str, Any]:
122
+ """Run actor synchronously (blocking).
123
+
124
+ Matches Apify's behavioral contract:
125
+ - Returns run dict regardless of final status (never raises on failure/timeout)
126
+ - On timeout, returns the current run state (status may be "RUNNING")
127
+ - Default wait_secs=None means wait indefinitely
128
+
129
+ Args:
130
+ run_input: Scraper configuration parameters
131
+ wait_secs: Maximum wait time in seconds (None = indefinite)
132
+ timeout_secs: Deprecated alias for wait_secs
133
+ memory_mbytes: Ignored (Apify compatibility)
134
+ build: Ignored (Apify compatibility)
135
+ webhooks: Per-run webhooks to register
136
+
137
+ Returns:
138
+ Dict in Apify run format
139
+ """
140
+ _ = memory_mbytes, build
141
+
142
+ effective_wait = self._resolve_wait_secs(wait_secs, timeout_secs)
143
+
144
+ job = self._create_job(run_input, webhooks=webhooks)
145
+ job_id = job["id"]
146
+
147
+ return self._wait_for_completion(job_id, effective_wait)
148
+
149
+ def start(
150
+ self,
151
+ run_input: dict[str, Any],
152
+ *,
153
+ webhooks: list[dict[str, Any]] | None = None,
154
+ wait_secs: int | None = None,
155
+ timeout_secs: int | None = None,
156
+ memory_mbytes: int | None = None,
157
+ build: str | None = None,
158
+ ) -> dict[str, Any]:
159
+ """Start actor asynchronously (non-blocking).
160
+
161
+ Returns immediately after creating the job.
162
+ """
163
+ _ = wait_secs, timeout_secs, memory_mbytes, build
164
+
165
+ job = self._create_job(run_input, webhooks=webhooks)
166
+ return _format_job_as_apify_run(job, scraper_type=self._scraper_type)
167
+
168
+ def _create_job(
169
+ self,
170
+ run_input: dict[str, Any],
171
+ webhooks: list[dict[str, Any]] | None = None,
172
+ ) -> dict[str, Any]:
173
+ config = self._transform_input(run_input)
174
+ payload: dict[str, Any] = {
175
+ "scraper_type": self._scraper_type,
176
+ "config": config,
177
+ }
178
+ if webhooks:
179
+ payload["webhooks"] = _translate_webhooks(webhooks)
180
+
181
+ return self._http.post("/api/v1/jobs/create/", json=payload)
182
+
183
+ def _transform_input(self, run_input: dict[str, Any]) -> dict[str, Any]:
184
+ return run_input.copy()
185
+
186
+ def _wait_for_completion(
187
+ self,
188
+ job_id: str,
189
+ wait_secs: int | None,
190
+ ) -> dict[str, Any]:
191
+ """Poll job status until terminal or timeout.
192
+
193
+ Returns Apify-format run dict in all cases (never raises).
194
+ On timeout, returns current run state. On 404, returns minimal dict.
195
+ """
196
+ start_time = time.monotonic()
197
+ poll_interval = DEFAULT_POLL_INTERVAL
198
+
199
+ while True:
200
+ job = self._http.get(f"/api/v1/jobs/{job_id}/")
201
+
202
+ if _is_terminal(job.get("status", "")):
203
+ return _format_job_as_apify_run(job, scraper_type=self._scraper_type)
204
+
205
+ if wait_secs is not None and (time.monotonic() - start_time) >= wait_secs:
206
+ return _format_job_as_apify_run(job, scraper_type=self._scraper_type)
207
+
208
+ time.sleep(poll_interval)
209
+ poll_interval = _next_poll_interval(poll_interval)
210
+
211
+ @staticmethod
212
+ def _resolve_wait_secs(
213
+ wait_secs: int | None,
214
+ timeout_secs: int | None,
215
+ ) -> int | None:
216
+ if timeout_secs is not None and wait_secs is None:
217
+ warnings.warn(
218
+ "timeout_secs is deprecated, use wait_secs instead",
219
+ DeprecationWarning,
220
+ stacklevel=3,
221
+ )
222
+ return timeout_secs
223
+ return wait_secs
224
+
225
+
226
+ class AsyncActorClient:
227
+ """Async actor client for GoFetch API."""
228
+
229
+ def __init__(
230
+ self,
231
+ http: AsyncHTTPClient,
232
+ scraper_type: str,
233
+ ) -> None:
234
+ self._http = http
235
+ self._scraper_type = scraper_type
236
+
237
+ async def call(
238
+ self,
239
+ run_input: dict[str, Any],
240
+ *,
241
+ wait_secs: int | None = None,
242
+ timeout_secs: int | None = None,
243
+ memory_mbytes: int | None = None,
244
+ build: str | None = None,
245
+ webhooks: list[dict[str, Any]] | None = None,
246
+ ) -> dict[str, Any]:
247
+ """Run actor synchronously (blocking). Async version."""
248
+ _ = memory_mbytes, build
249
+
250
+ effective_wait = ActorClient._resolve_wait_secs(wait_secs, timeout_secs)
251
+
252
+ job = await self._create_job(run_input, webhooks=webhooks)
253
+ job_id = job["id"]
254
+
255
+ return await self._wait_for_completion(job_id, effective_wait)
256
+
257
+ async def start(
258
+ self,
259
+ run_input: dict[str, Any],
260
+ *,
261
+ webhooks: list[dict[str, Any]] | None = None,
262
+ wait_secs: int | None = None,
263
+ timeout_secs: int | None = None,
264
+ memory_mbytes: int | None = None,
265
+ build: str | None = None,
266
+ ) -> dict[str, Any]:
267
+ """Start actor asynchronously (non-blocking). Async version."""
268
+ _ = wait_secs, timeout_secs, memory_mbytes, build
269
+
270
+ job = await self._create_job(run_input, webhooks=webhooks)
271
+ return _format_job_as_apify_run(job, scraper_type=self._scraper_type)
272
+
273
+ async def _create_job(
274
+ self,
275
+ run_input: dict[str, Any],
276
+ webhooks: list[dict[str, Any]] | None = None,
277
+ ) -> dict[str, Any]:
278
+ config = self._transform_input(run_input)
279
+ payload: dict[str, Any] = {
280
+ "scraper_type": self._scraper_type,
281
+ "config": config,
282
+ }
283
+ if webhooks:
284
+ payload["webhooks"] = _translate_webhooks(webhooks)
285
+
286
+ return await self._http.post("/api/v1/jobs/create/", json=payload)
287
+
288
+ def _transform_input(self, run_input: dict[str, Any]) -> dict[str, Any]:
289
+ return run_input.copy()
290
+
291
+ async def _wait_for_completion(
292
+ self,
293
+ job_id: str,
294
+ wait_secs: int | None,
295
+ ) -> dict[str, Any]:
296
+ """Poll job status until terminal or timeout. Async version."""
297
+ import asyncio
298
+
299
+ start_time = time.monotonic()
300
+ poll_interval = DEFAULT_POLL_INTERVAL
301
+
302
+ while True:
303
+ job = await self._http.get(f"/api/v1/jobs/{job_id}/")
304
+
305
+ if _is_terminal(job.get("status", "")):
306
+ return _format_job_as_apify_run(job, scraper_type=self._scraper_type)
307
+
308
+ if wait_secs is not None and (time.monotonic() - start_time) >= wait_secs:
309
+ return _format_job_as_apify_run(job, scraper_type=self._scraper_type)
310
+
311
+ await asyncio.sleep(poll_interval)
312
+ poll_interval = _next_poll_interval(poll_interval)
gofetch/client.py ADDED
@@ -0,0 +1,305 @@
1
+ """
2
+ Main GoFetch client.
3
+
4
+ Provides the primary entry point for interacting with the GoFetch API.
5
+ Designed as a drop-in replacement for ApifyClient.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import NoReturn
11
+
12
+ from gofetch.actor import ActorClient, AsyncActorClient
13
+ from gofetch.constants import DEFAULT_BASE_URL, DEFAULT_TIMEOUT
14
+ from gofetch.dataset import AsyncDatasetClient, DatasetClient
15
+ from gofetch.http import AsyncHTTPClient, HTTPClient
16
+ from gofetch.run import AsyncRunClient, RunClient
17
+ from gofetch.types import resolve_actor_url
18
+ from gofetch.webhook_client import (
19
+ AsyncWebhookClient,
20
+ AsyncWebhookCollectionClient,
21
+ WebhookClient,
22
+ WebhookCollectionClient,
23
+ )
24
+
25
+
26
+ class GoFetchClient:
27
+ """
28
+ GoFetch API client - drop-in replacement for ApifyClient.
29
+
30
+ This client provides the same interface as Apify's SDK, allowing
31
+ you to switch from Apify to GoFetch with minimal code changes.
32
+
33
+ Usage:
34
+ # Initialize client
35
+ client = GoFetchClient(api_key="sk_scr_myorg_xxxx")
36
+
37
+ # Get actor client (same as Apify)
38
+ actor = client.actor("apify/instagram-scraper") # Apify URL works!
39
+ # Or use GoFetch scraper type directly
40
+ actor = client.actor("instagram")
41
+
42
+ # Run synchronously (blocking)
43
+ run = actor.call(run_input={"directUrls": ["https://instagram.com/nike"]})
44
+ print(f"Job completed: {run['id']}")
45
+
46
+ # Fetch results
47
+ dataset = client.dataset(run["defaultDatasetId"])
48
+ for item in dataset.iterate_items():
49
+ print(item)
50
+
51
+ # Run asynchronously (with webhooks)
52
+ run = actor.start(
53
+ run_input={"directUrls": ["https://instagram.com/nike"]},
54
+ webhooks=[{
55
+ "request_url": "https://myapp.com/webhook",
56
+ "event_types": ["ACTOR.RUN.SUCCEEDED"]
57
+ }]
58
+ )
59
+ print(f"Job started: {run['id']}")
60
+
61
+ Attributes:
62
+ base_url: The API base URL
63
+ timeout: Request timeout in seconds
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ api_key: str | None = None,
69
+ token: str | None = None, # Alias for api_key (Apify compat)
70
+ base_url: str = DEFAULT_BASE_URL,
71
+ timeout: float = DEFAULT_TIMEOUT,
72
+ max_retries: int = 3,
73
+ ) -> None:
74
+ """
75
+ Initialize the GoFetch client.
76
+
77
+ Args:
78
+ api_key: GoFetch API key (format: sk_scr_...)
79
+ token: Alias for api_key (for Apify compatibility)
80
+ base_url: API base URL (default: https://api.go-fetch.io)
81
+ timeout: Request timeout in seconds (default: 30)
82
+ max_retries: Maximum retries for failed requests (default: 3)
83
+
84
+ Raises:
85
+ ValueError: If neither api_key nor token is provided
86
+ """
87
+ # Support both api_key and token for Apify compatibility
88
+ self._api_key = api_key or token
89
+ if not self._api_key:
90
+ raise ValueError("Either 'api_key' or 'token' must be provided")
91
+
92
+ self._base_url = base_url.rstrip("/")
93
+ self._timeout = timeout
94
+ self._max_retries = max_retries
95
+
96
+ self._http = HTTPClient(
97
+ api_key=self._api_key,
98
+ base_url=self._base_url,
99
+ timeout=self._timeout,
100
+ max_retries=self._max_retries,
101
+ )
102
+
103
+ @property
104
+ def base_url(self) -> str:
105
+ """Get the API base URL."""
106
+ return self._base_url
107
+
108
+ @property
109
+ def timeout(self) -> float:
110
+ """Get the request timeout."""
111
+ return self._timeout
112
+
113
+ def actor(self, actor_url: str) -> ActorClient:
114
+ """
115
+ Get an actor client for a specific scraper.
116
+
117
+ Args:
118
+ actor_url: Apify-style actor URL or GoFetch scraper type.
119
+ Supported values:
120
+ - "apify/instagram-scraper" -> instagram
121
+ - "apify/instagram-profile-scraper" -> instagram_profile
122
+ - "clockworks/tiktok-profile-scraper" -> tiktok
123
+ - "streamers/youtube-scraper" -> youtube
124
+ - Or direct: "instagram", "tiktok", "youtube"
125
+
126
+ Returns:
127
+ ActorClient instance for the specified scraper
128
+
129
+ Raises:
130
+ ValueError: If actor_url is empty
131
+
132
+ Example:
133
+ # Using Apify URL (for compatibility)
134
+ actor = client.actor("apify/instagram-scraper")
135
+
136
+ # Using GoFetch type directly
137
+ actor = client.actor("instagram")
138
+ """
139
+ if not actor_url:
140
+ raise ValueError("actor_id must not be empty")
141
+ scraper_type = resolve_actor_url(actor_url)
142
+ return ActorClient(
143
+ http=self._http,
144
+ scraper_type=scraper_type,
145
+ )
146
+
147
+ def dataset(self, dataset_id: str) -> DatasetClient:
148
+ """
149
+ Get a dataset client to fetch results.
150
+
151
+ Args:
152
+ dataset_id: Dataset/Job ID. In GoFetch, the job ID serves
153
+ as the dataset ID (they are equivalent).
154
+
155
+ Returns:
156
+ DatasetClient instance for fetching results
157
+
158
+ Example:
159
+ run = actor.call(run_input={...})
160
+ dataset = client.dataset(run["defaultDatasetId"])
161
+ items = list(dataset.iterate_items())
162
+ """
163
+ return DatasetClient(
164
+ http=self._http,
165
+ job_id=dataset_id,
166
+ )
167
+
168
+ def run(self, run_id: str) -> RunClient:
169
+ """Get a run client for a specific job."""
170
+ return RunClient(http=self._http, run_id=run_id)
171
+
172
+ def webhook(self, webhook_id: str) -> WebhookClient:
173
+ """Get a webhook client for a specific webhook."""
174
+ return WebhookClient(http=self._http, webhook_id=webhook_id)
175
+
176
+ def webhooks(self) -> WebhookCollectionClient:
177
+ """Get webhook collection client for listing/creating webhooks."""
178
+ return WebhookCollectionClient(http=self._http)
179
+
180
+ def key_value_store(self, store_id: str) -> NoReturn:
181
+ """Not supported in GoFetch.
182
+
183
+ Apify's key-value store has no GoFetch equivalent.
184
+ """
185
+ raise NotImplementedError(
186
+ f"GoFetch does not have a key-value store. "
187
+ f"key_value_store('{store_id}') cannot be used. "
188
+ f"Media uploaded by GoFetch scrapers is accessible via direct URLs "
189
+ f"and does not require manual cleanup."
190
+ )
191
+
192
+ def close(self) -> None:
193
+ """Close the client and release resources."""
194
+ self._http.close()
195
+
196
+ def __enter__(self) -> GoFetchClient:
197
+ """Context manager entry."""
198
+ return self
199
+
200
+ def __exit__(self, *args: object) -> None:
201
+ """Context manager exit."""
202
+ self.close()
203
+
204
+
205
+ class AsyncGoFetchClient:
206
+ """
207
+ Async GoFetch API client.
208
+
209
+ Same interface as GoFetchClient but uses async/await.
210
+
211
+ Usage:
212
+ async with AsyncGoFetchClient(api_key="...") as client:
213
+ actor = client.actor("instagram")
214
+ run = await actor.call(run_input={...})
215
+ dataset = client.dataset(run["defaultDatasetId"])
216
+ items = await dataset.list_items()
217
+ """
218
+
219
+ def __init__(
220
+ self,
221
+ api_key: str | None = None,
222
+ token: str | None = None,
223
+ base_url: str = DEFAULT_BASE_URL,
224
+ timeout: float = DEFAULT_TIMEOUT,
225
+ max_retries: int = 3,
226
+ ) -> None:
227
+ """Initialize the async GoFetch client."""
228
+ self._api_key = api_key or token
229
+ if not self._api_key:
230
+ raise ValueError("Either 'api_key' or 'token' must be provided")
231
+
232
+ self._base_url = base_url.rstrip("/")
233
+ self._timeout = timeout
234
+ self._max_retries = max_retries
235
+
236
+ self._http = AsyncHTTPClient(
237
+ api_key=self._api_key,
238
+ base_url=self._base_url,
239
+ timeout=self._timeout,
240
+ max_retries=self._max_retries,
241
+ )
242
+
243
+ @property
244
+ def base_url(self) -> str:
245
+ """Get the API base URL."""
246
+ return self._base_url
247
+
248
+ @property
249
+ def timeout(self) -> float:
250
+ """Get the request timeout."""
251
+ return self._timeout
252
+
253
+ def actor(self, actor_url: str) -> AsyncActorClient:
254
+ """Get an async actor client for a specific scraper."""
255
+ if not actor_url:
256
+ raise ValueError("actor_id must not be empty")
257
+ scraper_type = resolve_actor_url(actor_url)
258
+ return AsyncActorClient(
259
+ http=self._http,
260
+ scraper_type=scraper_type,
261
+ )
262
+
263
+ def dataset(self, dataset_id: str) -> AsyncDatasetClient:
264
+ """Get an async dataset client to fetch results."""
265
+ return AsyncDatasetClient(
266
+ http=self._http,
267
+ job_id=dataset_id,
268
+ )
269
+
270
+ def run(self, run_id: str) -> AsyncRunClient:
271
+ """Get an async run client for a specific job."""
272
+ return AsyncRunClient(http=self._http, run_id=run_id)
273
+
274
+ def webhook(self, webhook_id: str) -> AsyncWebhookClient:
275
+ """Get an async webhook client for a specific webhook."""
276
+ return AsyncWebhookClient(http=self._http, webhook_id=webhook_id)
277
+
278
+ def webhooks(self) -> AsyncWebhookCollectionClient:
279
+ """Get async webhook collection client for listing/creating webhooks."""
280
+ return AsyncWebhookCollectionClient(http=self._http)
281
+
282
+ def key_value_store(self, store_id: str) -> NoReturn:
283
+ """Not supported in GoFetch."""
284
+ raise NotImplementedError(
285
+ f"GoFetch does not have a key-value store. "
286
+ f"key_value_store('{store_id}') cannot be used. "
287
+ f"Media uploaded by GoFetch scrapers is accessible via direct URLs "
288
+ f"and does not require manual cleanup."
289
+ )
290
+
291
+ async def close(self) -> None:
292
+ """Close the client and release resources."""
293
+ await self._http.close()
294
+
295
+ async def __aenter__(self) -> AsyncGoFetchClient:
296
+ """Async context manager entry."""
297
+ return self
298
+
299
+ async def __aexit__(self, *args: object) -> None:
300
+ """Async context manager exit."""
301
+ await self.close()
302
+
303
+
304
+ # Alias for Apify compatibility
305
+ ApifyClient = GoFetchClient