keble-scraper-api 0.2.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 (42) hide show
  1. keble_scraper_api/__init__.py +3 -0
  2. keble_scraper_api/api/dependencies.py +100 -0
  3. keble_scraper_api/api/errors.py +72 -0
  4. keble_scraper_api/api/router.py +473 -0
  5. keble_scraper_api/api/schemas.py +26 -0
  6. keble_scraper_api/application/admin.py +285 -0
  7. keble_scraper_api/application/artifacts.py +393 -0
  8. keble_scraper_api/application/auth.py +324 -0
  9. keble_scraper_api/application/failures.py +427 -0
  10. keble_scraper_api/application/fetching.py +72 -0
  11. keble_scraper_api/application/jobs.py +891 -0
  12. keble_scraper_api/application/proxy_health.py +126 -0
  13. keble_scraper_api/application/scheduling.py +24 -0
  14. keble_scraper_api/artifact_namespace.py +76 -0
  15. keble_scraper_api/container.py +361 -0
  16. keble_scraper_api/errors.py +25 -0
  17. keble_scraper_api/infrastructure/artifact_store.py +585 -0
  18. keble_scraper_api/infrastructure/callbacks.py +285 -0
  19. keble_scraper_api/infrastructure/celery_factory.py +63 -0
  20. keble_scraper_api/infrastructure/cloudflare.py +259 -0
  21. keble_scraper_api/infrastructure/credentials.py +75 -0
  22. keble_scraper_api/infrastructure/dispatcher.py +57 -0
  23. keble_scraper_api/infrastructure/http_fetcher.py +351 -0
  24. keble_scraper_api/infrastructure/mongo.py +2345 -0
  25. keble_scraper_api/infrastructure/proxy_router.py +460 -0
  26. keble_scraper_api/infrastructure/request_profiles.py +220 -0
  27. keble_scraper_api/infrastructure/url_policy.py +341 -0
  28. keble_scraper_api/main.py +162 -0
  29. keble_scraper_api/maintenance/__init__.py +1 -0
  30. keble_scraper_api/maintenance/reset_pre_release_state.py +743 -0
  31. keble_scraper_api/models.py +473 -0
  32. keble_scraper_api/protocols.py +947 -0
  33. keble_scraper_api/settings.py +484 -0
  34. keble_scraper_api/telemetry.py +109 -0
  35. keble_scraper_api/workers/celery_app.py +48 -0
  36. keble_scraper_api/workers/cli.py +106 -0
  37. keble_scraper_api/workers/runtime.py +78 -0
  38. keble_scraper_api/workers/tasks.py +97 -0
  39. keble_scraper_api-0.2.0.dist-info/METADATA +160 -0
  40. keble_scraper_api-0.2.0.dist-info/RECORD +42 -0
  41. keble_scraper_api-0.2.0.dist-info/WHEEL +4 -0
  42. keble_scraper_api-0.2.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,126 @@
1
+ """Quarantined public-proxy health checking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import timedelta
6
+
7
+ from keble_scraper_contract import (
8
+ ProxyHealthObservation,
9
+ ScrapeTransportRoute,
10
+ utc_now,
11
+ )
12
+ from keble_helpers.typings import PydanticModelConfig
13
+ from pydantic import BaseModel, IPvAnyAddress
14
+
15
+ from keble_scraper_api.application.fetching import FetchResult, PreparedFetch
16
+ from keble_scraper_api.models import RouteReservation
17
+ from keble_scraper_api.protocols import HttpFetcherProtocol, ProxyRepositoryProtocol
18
+ from keble_scraper_api.settings import ScraperSettings
19
+
20
+
21
+ class ProxyHealthPayload(BaseModel):
22
+ """Validated egress identity returned by the fixed health endpoint.
23
+
24
+ Side effects if changes:
25
+ - proxy health evidence and operator diagnostics.
26
+ """
27
+
28
+ model_config = PydanticModelConfig.default(frozen=True, extra="ignore")
29
+
30
+ ip: IPvAnyAddress
31
+
32
+
33
+ class ProxyHealthService:
34
+ """Test quarantined proxies against one fixed public TLS endpoint.
35
+
36
+ Steps:
37
+ 1. inspect only a bounded set of due quarantined candidates;
38
+ 2. use the shared secure fetcher against a fixed endpoint;
39
+ 3. persist typed health evidence only after response validation.
40
+
41
+ Side effects if changes:
42
+ - experimental proxy traffic and promotion/decay state.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ repository: ProxyRepositoryProtocol,
49
+ fetcher: HttpFetcherProtocol,
50
+ settings: ScraperSettings,
51
+ ) -> None:
52
+ """Bind proxy persistence and the same secure HTTP transport.
53
+
54
+ Side effects if changes:
55
+ - all proxy-health worker runs.
56
+ """
57
+
58
+ self._repository = repository
59
+ self._fetcher = fetcher
60
+ self._settings = settings
61
+
62
+ async def run_due(self, *, limit: int = 100) -> int:
63
+ """Check due candidates sequentially and append bounded observations.
64
+
65
+ Steps:
66
+ 1. load bounded due candidates and their secret credentials;
67
+ 2. fetch the fixed endpoint while allowing implementation faults to supervise;
68
+ 3. record only typed HTTP/payload outcomes as health evidence.
69
+
70
+ Side effects if changes:
71
+ - public network calls and candidate health state updates.
72
+ """
73
+
74
+ candidates = await self._repository.due_candidates(now=utc_now(), limit=limit)
75
+ for candidate in candidates:
76
+ credential = await self._repository.credential_for(
77
+ candidate_id=candidate.candidate_id
78
+ )
79
+ request = PreparedFetch(
80
+ url="https://api.ipify.org/?format=json",
81
+ method="GET",
82
+ headers={"Accept": "application/json"},
83
+ request_identity=f"proxy-health:{candidate.candidate_id}",
84
+ )
85
+ result = await self._fetcher.fetch(
86
+ request=request,
87
+ reservation=RouteReservation(
88
+ route=ScrapeTransportRoute.PUBLIC_POOL_EXPERIMENT,
89
+ proxy_url=credential,
90
+ ),
91
+ response_policy=self._settings.response_policy("JSON"),
92
+ )
93
+ parsed = await self._parse_payload(result=result)
94
+ succeeded = parsed is not None
95
+ observation = ProxyHealthObservation(
96
+ succeeded=succeeded,
97
+ latency_ms=result.elapsed_ms,
98
+ egress_ip=str(parsed.ip) if parsed is not None else None,
99
+ tls_valid=succeeded,
100
+ )
101
+ await self._repository.record_health(
102
+ candidate_id=candidate.candidate_id,
103
+ observation=observation,
104
+ next_health_at=utc_now() + timedelta(minutes=15),
105
+ )
106
+ return len(candidates)
107
+
108
+ @staticmethod
109
+ async def _parse_payload(
110
+ *,
111
+ result: FetchResult,
112
+ ) -> ProxyHealthPayload | None:
113
+ """Accept only a successful typed response containing one valid IP.
114
+
115
+ Steps:
116
+ 1. treat a finite non-success transport result as failed health evidence;
117
+ 2. validate a success body through the strict Pydantic payload;
118
+ 3. let malformed success bodies surface as provider/implementation faults.
119
+
120
+ Side effects if changes:
121
+ - proxy promotion and recorded egress identity.
122
+ """
123
+
124
+ if not result.succeeded:
125
+ return None
126
+ return ProxyHealthPayload.model_validate_json(result.body)
@@ -0,0 +1,24 @@
1
+ """Durable scheduling precision shared by jobs, failures, and callbacks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta
6
+
7
+
8
+ def ceil_persistence_millisecond(*, value: datetime) -> datetime:
9
+ """Round one due horizon upward to MongoDB BSON millisecond precision.
10
+
11
+ Steps:
12
+ 1. retain timestamps already aligned to an exact millisecond;
13
+ 2. add only the missing microseconds to reach the next millisecond;
14
+ 3. preserve timezone information and never schedule before the input horizon.
15
+
16
+ Side effects if changes:
17
+ - job and callback retries must not run before provider/local wait horizons;
18
+ - append-only failure evidence must equal its persisted scheduling precision.
19
+ """
20
+
21
+ remainder = value.microsecond % 1_000
22
+ if remainder == 0:
23
+ return value
24
+ return value + timedelta(microseconds=1_000 - remainder)
@@ -0,0 +1,76 @@
1
+ """Strongly typed artifact-store identity used by Mongo and binary stores."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+
7
+ from keble_helpers.typings import PydanticModelConfig
8
+ from pydantic import BaseModel, Field, model_validator
9
+
10
+
11
+ class ArtifactStoreBackend(StrEnum):
12
+ """Enumerate storage backends whose object keys are not interchangeable.
13
+
14
+ Steps:
15
+ 1. distinguish durable OSS, shared local directory, and test memory stores.
16
+
17
+ Side effects if changes:
18
+ - Mongo namespace markers persist these values as compatibility evidence;
19
+ - every new backend must implement the artifact-store owner-claim protocol.
20
+ """
21
+
22
+ OSS = "oss"
23
+ LOCAL_DIRECTORY = "local_directory"
24
+ MEMORY = "memory"
25
+
26
+
27
+ class ArtifactStoreIdentity(BaseModel):
28
+ """Describe every non-secret location component that determines stored bytes.
29
+
30
+ Steps:
31
+ 1. retain backend kind plus its canonical object-key prefix;
32
+ 2. require endpoint/bucket only for OSS and directory only for local storage;
33
+ 3. forbid irrelevant fields so backend swaps cannot compare equal.
34
+
35
+ Side effects if changes:
36
+ - scraper startup compares this exact value with the Mongo namespace marker;
37
+ - changing OSS endpoint/bucket or local directory requires explicit migration;
38
+ - access credentials and download base URLs remain safely excluded.
39
+ """
40
+
41
+ model_config = PydanticModelConfig.default(frozen=True, extra="forbid")
42
+
43
+ backend: ArtifactStoreBackend
44
+ prefix: str = Field(min_length=1, max_length=512)
45
+ endpoint: str | None = Field(default=None, min_length=1, max_length=2_048)
46
+ bucket: str | None = Field(default=None, min_length=1, max_length=255)
47
+ directory: str | None = Field(default=None, min_length=1, max_length=4_096)
48
+
49
+ @model_validator(mode="after")
50
+ def validate_backend_fields(self) -> "ArtifactStoreIdentity":
51
+ """Require the exact location fields owned by the selected backend.
52
+
53
+ Steps:
54
+ 1. validate endpoint/bucket for OSS;
55
+ 2. validate directory for local storage;
56
+ 3. require memory identity to contain only its fixed prefix.
57
+
58
+ Side effects if changes:
59
+ - malformed markers must fail before artifact reads or cleanup;
60
+ - identity equality remains a total backend/location comparison.
61
+ """
62
+
63
+ if self.backend is ArtifactStoreBackend.OSS:
64
+ if self.endpoint is None or self.bucket is None or self.directory is not None:
65
+ raise ValueError("OSS identity requires endpoint/bucket and forbids directory")
66
+ elif self.backend is ArtifactStoreBackend.LOCAL_DIRECTORY:
67
+ if self.directory is None or self.endpoint is not None or self.bucket is not None:
68
+ raise ValueError(
69
+ "local artifact identity requires directory and forbids OSS fields"
70
+ )
71
+ elif any(value is not None for value in (self.endpoint, self.bucket, self.directory)):
72
+ raise ValueError("memory artifact identity forbids external location fields")
73
+ return self
74
+
75
+
76
+ __all__ = ["ArtifactStoreBackend", "ArtifactStoreIdentity"]
@@ -0,0 +1,361 @@
1
+ """Explicit process composition for API and worker runtimes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ from dataclasses import dataclass
8
+
9
+ from celery import Celery
10
+ from pydantic import SecretStr
11
+ from redis.asyncio import Redis
12
+
13
+ from keble_scraper_api.application.admin import (
14
+ ArtifactAdminService,
15
+ MaintenanceAdminService,
16
+ ProxyAdminService,
17
+ )
18
+ from keble_scraper_api.application.artifacts import ArtifactService
19
+ from keble_scraper_api.application.auth import (
20
+ OperatorAuthService,
21
+ SessionCipher,
22
+ StaticServiceKeyAuthorizer,
23
+ )
24
+ from keble_scraper_api.application.jobs import (
25
+ AuditService,
26
+ MaintenanceService,
27
+ ScrapeExecutionService,
28
+ ScrapeJobService,
29
+ )
30
+ from keble_scraper_api.application.proxy_health import ProxyHealthService
31
+ from keble_scraper_api.infrastructure.artifact_store import (
32
+ InMemoryArtifactStore,
33
+ LocalDirectoryArtifactStore,
34
+ OssArtifactStore,
35
+ )
36
+ from keble_scraper_api.infrastructure.callbacks import (
37
+ CallbackOutboxService,
38
+ RegisteredHttpCompletionCallback,
39
+ WebhookSigner,
40
+ )
41
+ from keble_scraper_api.infrastructure.celery_factory import build_celery
42
+ from keble_scraper_api.infrastructure.cloudflare import CloudflareBrowserFetcher
43
+ from keble_scraper_api.infrastructure.credentials import ProxyCredentialCipher
44
+ from keble_scraper_api.infrastructure.dispatcher import CeleryJobDispatcher
45
+ from keble_scraper_api.infrastructure.http_fetcher import SecureHttpFetcher
46
+ from keble_scraper_api.infrastructure.mongo import MongoScraperRepository
47
+ from keble_scraper_api.infrastructure.proxy_router import RedisProxyRouter
48
+ from keble_scraper_api.infrastructure.request_profiles import RequestProfileRegistry
49
+ from keble_scraper_api.infrastructure.url_policy import (
50
+ DnsOverHttpsHostResolver,
51
+ PublicUrlPolicy,
52
+ )
53
+ from keble_scraper_api.protocols import ArtifactStoreProtocol
54
+ from keble_scraper_api.settings import RuntimeEnvironment, ScraperSettings
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class ScraperContainer:
59
+ """Fully assembled process service graph with explicit resource ownership.
60
+
61
+ Steps:
62
+ 1. construct one process graph from a singular settings object;
63
+ 2. start indexes, namespace fences, and readiness before use;
64
+ 3. close every pooled external resource in deterministic order.
65
+
66
+ Side effects if changes:
67
+ - API/worker startup order and every external infrastructure client.
68
+ """
69
+
70
+ settings: ScraperSettings
71
+ repository: MongoScraperRepository
72
+ redis: Redis
73
+ celery: Celery
74
+ artifact_store: ArtifactStoreProtocol
75
+ http_fetcher: SecureHttpFetcher
76
+ browser_fetcher: CloudflareBrowserFetcher
77
+ callback_transport: RegisteredHttpCompletionCallback
78
+ auth: OperatorAuthService
79
+ service_keys: StaticServiceKeyAuthorizer
80
+ jobs: ScrapeJobService
81
+ execution: ScrapeExecutionService
82
+ maintenance: MaintenanceService
83
+ proxy_health: ProxyHealthService
84
+ callbacks: CallbackOutboxService
85
+ proxy_admin: ProxyAdminService
86
+ artifact_admin: ArtifactAdminService
87
+ maintenance_admin: MaintenanceAdminService
88
+
89
+ @classmethod
90
+ def build(cls, *, settings: ScraperSettings | None = None) -> "ScraperContainer":
91
+ """Construct the complete graph once from one typed settings value.
92
+
93
+ Steps:
94
+ 1. create shared Mongo/Redis/Rabbit clients and encrypted credential owner;
95
+ 2. wire protocols into application services with no service locator;
96
+ 3. select private OSS in configured environments and isolated memory only
97
+ for local/test operation without OSS credentials.
98
+
99
+ Side effects if changes:
100
+ - connections, vendor clients, queue publication, and auth behavior.
101
+ """
102
+
103
+ resolved = settings or ScraperSettings()
104
+ proxy_key = _stable_fernet_key(
105
+ configured=resolved.proxy_secret_fernet_key,
106
+ fallback=resolved.service_key_current,
107
+ )
108
+ proxy_cipher = ProxyCredentialCipher(fernet_key=proxy_key)
109
+ repository = MongoScraperRepository.build(
110
+ settings=resolved,
111
+ proxy_cipher=proxy_cipher,
112
+ )
113
+ redis = Redis.from_url(
114
+ resolved.redis_uri.get_secret_value(),
115
+ decode_responses=True,
116
+ )
117
+ celery = build_celery(settings=resolved)
118
+ dispatcher = CeleryJobDispatcher(celery=celery, settings=resolved)
119
+ profiles = RequestProfileRegistry()
120
+ url_policy = PublicUrlPolicy(
121
+ resolver=(
122
+ DnsOverHttpsHostResolver(
123
+ endpoint=resolved.development_dns_over_https_url,
124
+ proxy_url=resolved.development_http_proxy_url,
125
+ )
126
+ if resolved.development_dns_over_https_url is not None
127
+ and resolved.development_http_proxy_url is not None
128
+ else None
129
+ )
130
+ )
131
+ http_fetcher = SecureHttpFetcher(settings=resolved, url_policy=url_policy)
132
+ browser_fetcher = CloudflareBrowserFetcher(
133
+ settings=resolved,
134
+ url_policy=url_policy,
135
+ )
136
+ proxy_router = RedisProxyRouter(
137
+ redis=redis,
138
+ settings=resolved,
139
+ proxy_repository=repository,
140
+ )
141
+ artifact_store = build_artifact_store(settings=resolved)
142
+ artifacts = ArtifactService(repository=repository, store=artifact_store)
143
+ audit = AuditService(repository=repository)
144
+ jobs = ScrapeJobService(
145
+ repository=repository,
146
+ dispatcher=dispatcher,
147
+ profiles=profiles,
148
+ audit=audit,
149
+ )
150
+ execution = ScrapeExecutionService(
151
+ settings=resolved,
152
+ jobs=repository,
153
+ cache=repository,
154
+ artifacts=artifacts,
155
+ router=proxy_router,
156
+ profiles=profiles,
157
+ http_fetcher=http_fetcher,
158
+ browser_fetcher=browser_fetcher,
159
+ )
160
+ maintenance = MaintenanceService(
161
+ jobs=repository,
162
+ outbox=repository,
163
+ cache=repository,
164
+ artifacts=artifacts,
165
+ history=repository,
166
+ dispatcher=dispatcher,
167
+ dispatch_concurrency=resolved.maintenance_dispatch_concurrency,
168
+ history_retention_batch_size=resolved.history_retention_batch_size,
169
+ )
170
+ signer = WebhookSigner(key=resolved.callback_hmac_current)
171
+ callback = RegisteredHttpCompletionCallback(settings=resolved, signer=signer)
172
+ callbacks = CallbackOutboxService(
173
+ repository=repository,
174
+ callback=callback,
175
+ lease_seconds=resolved.worker_lease_seconds,
176
+ retry_base_seconds=resolved.retry_base_seconds,
177
+ )
178
+ proxy_health = ProxyHealthService(
179
+ repository=repository,
180
+ fetcher=http_fetcher,
181
+ settings=resolved,
182
+ )
183
+ session_key = (
184
+ resolved.session_fernet_key
185
+ if resolved.session_fernet_key is not None
186
+ else _stable_fernet_key(
187
+ configured=None,
188
+ fallback=resolved.callback_hmac_current,
189
+ )
190
+ )
191
+ auth = OperatorAuthService(
192
+ settings=resolved,
193
+ cipher=SessionCipher(key=session_key),
194
+ )
195
+ service_keys = StaticServiceKeyAuthorizer(
196
+ current=resolved.service_key_current,
197
+ next_key=resolved.service_key_next,
198
+ )
199
+ proxy_admin = ProxyAdminService(
200
+ repository=repository,
201
+ router=proxy_router,
202
+ audit=audit,
203
+ settings=resolved,
204
+ )
205
+ artifact_admin = ArtifactAdminService(
206
+ artifacts=artifacts,
207
+ audit=audit,
208
+ settings=resolved,
209
+ )
210
+ maintenance_admin = MaintenanceAdminService(
211
+ maintenance=maintenance,
212
+ audit=audit,
213
+ )
214
+ return cls(
215
+ settings=resolved,
216
+ repository=repository,
217
+ redis=redis,
218
+ celery=celery,
219
+ artifact_store=artifact_store,
220
+ http_fetcher=http_fetcher,
221
+ browser_fetcher=browser_fetcher,
222
+ callback_transport=callback,
223
+ auth=auth,
224
+ service_keys=service_keys,
225
+ jobs=jobs,
226
+ execution=execution,
227
+ maintenance=maintenance,
228
+ proxy_health=proxy_health,
229
+ callbacks=callbacks,
230
+ proxy_admin=proxy_admin,
231
+ artifact_admin=artifact_admin,
232
+ maintenance_admin=maintenance_admin,
233
+ )
234
+
235
+ async def start(self) -> None:
236
+ """Create indexes and verify namespace/shared resources before work.
237
+
238
+ Steps:
239
+ 1. create required Mongo indexes;
240
+ 2. claim the binary-store namespace for one stable deployment owner;
241
+ 3. bind Mongo artifact rows to the complete binary-store identity;
242
+ 4. verify Mongo and Redis readiness only after both fences pass.
243
+
244
+ Side effects if changes:
245
+ - Mongo index/namespace state and process readiness.
246
+ """
247
+
248
+ await self.repository.ensure_indexes()
249
+ store_identity = self.artifact_store.namespace_identity()
250
+ namespace_owner = self.settings.resolved_artifact_namespace_owner
251
+ await self.artifact_store.ensure_namespace_owner(owner=namespace_owner)
252
+ await self.repository.ensure_artifact_store_namespace(
253
+ probe_object_key=self.artifact_store.object_key(content_sha256="0" * 64),
254
+ store_identity=store_identity,
255
+ namespace_owner=namespace_owner,
256
+ )
257
+ await self.repository.readiness()
258
+ await self.redis.ping()
259
+
260
+ async def close(self) -> None:
261
+ """Close pooled HTTP, Redis, then Mongo process-owned resources.
262
+
263
+ Steps:
264
+ 1. close direct/proxy, Cloudflare, and callback pooled HTTP transports;
265
+ 2. close the shared Redis connection pool;
266
+ 3. close the Mongo client after no service can issue more work.
267
+
268
+ Side effects if changes:
269
+ - graceful API/worker shutdown and connection leakage.
270
+ """
271
+
272
+ await self.http_fetcher.close()
273
+ await self.browser_fetcher.close()
274
+ await self.callback_transport.close()
275
+ await self.redis.aclose()
276
+ await self.repository.close()
277
+
278
+ async def readiness(self) -> dict[str, str]:
279
+ """Verify Mongo/Redis without privileged vendor or service calls.
280
+
281
+ Side effects if changes:
282
+ - deployment health gates and rollback decisions.
283
+ """
284
+
285
+ await self.repository.readiness()
286
+ await self.redis.ping()
287
+ return {
288
+ "status": "ready",
289
+ "mongo": "ready",
290
+ "redis": "ready",
291
+ "artifactStore": (
292
+ "private-oss"
293
+ if self.settings.oss_bucket is not None
294
+ else "shared-local-directory"
295
+ if self.settings.local_artifact_directory is not None
296
+ else "ephemeral-development"
297
+ ),
298
+ }
299
+
300
+
301
+ def _stable_fernet_key(
302
+ *, configured: SecretStr | None, fallback: SecretStr
303
+ ) -> SecretStr:
304
+ """Derive a deterministic URL-safe key only for non-production fallback use.
305
+
306
+ Side effects if changes:
307
+ - development proxy/session ciphertext compatibility across processes.
308
+ """
309
+
310
+ if configured is not None:
311
+ return configured
312
+ digest = hashlib.sha256(fallback.get_secret_value().encode()).digest()
313
+ return SecretStr(base64.urlsafe_b64encode(digest).decode())
314
+
315
+
316
+ def build_artifact_store(*, settings: ScraperSettings) -> ArtifactStoreProtocol:
317
+ """Select private OSS or an explicit non-production ephemeral test store.
318
+
319
+ Steps:
320
+ 1. prefer the complete private OSS tuple when configured;
321
+ 2. otherwise select the explicit local-directory pair in local/test;
322
+ 3. use process-memory storage only for credential-free local/test operation.
323
+
324
+ Side effects if changes:
325
+ - API, workers, and guarded maintenance commands share this sole selector;
326
+ - artifact durability and raw-download availability depend on the result.
327
+ """
328
+
329
+ configured = (
330
+ settings.oss_endpoint,
331
+ settings.oss_bucket,
332
+ settings.oss_access_key_id,
333
+ settings.oss_access_key_secret,
334
+ )
335
+ if all(value is not None for value in configured):
336
+ return OssArtifactStore(
337
+ endpoint=str(settings.oss_endpoint or ""),
338
+ bucket_name=settings.oss_bucket or "",
339
+ access_key_id=settings.oss_access_key_id or "",
340
+ access_key_secret=(
341
+ settings.oss_access_key_secret.get_secret_value()
342
+ if settings.oss_access_key_secret is not None
343
+ else ""
344
+ ),
345
+ prefix=settings.oss_prefix,
346
+ )
347
+ if (
348
+ settings.local_artifact_directory is not None
349
+ and settings.local_artifact_base_url is not None
350
+ ):
351
+ return LocalDirectoryArtifactStore(
352
+ directory=settings.local_artifact_directory,
353
+ base_url=str(settings.local_artifact_base_url),
354
+ prefix=settings.oss_prefix,
355
+ )
356
+ if settings.environment in {
357
+ RuntimeEnvironment.DEVELOPMENT,
358
+ RuntimeEnvironment.TEST,
359
+ }:
360
+ return InMemoryArtifactStore()
361
+ raise ValueError("private OSS configuration is required outside development/test")
@@ -0,0 +1,25 @@
1
+ """Typed sanitized scraper application failures."""
2
+
3
+ from keble_scraper_contract import ScrapeLocalFailureCode
4
+
5
+
6
+ ScraperErrorCode = ScrapeLocalFailureCode
7
+
8
+
9
+ class ScraperDomainError(Exception):
10
+ """Sanitized typed failure crossing application and HTTP boundaries.
11
+
12
+ Side effects if changes:
13
+ - worker outcome conversion and FastAPI exception responses.
14
+ """
15
+
16
+ def __init__(self, *, code: ScraperErrorCode, message: str) -> None:
17
+ """Initialize one safe code/message pair without transport details.
18
+
19
+ Side effects if changes:
20
+ - every raised scraper domain failure serializes through this value.
21
+ """
22
+
23
+ super().__init__(message)
24
+ self.code = code
25
+ self.message = message