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,285 @@
1
+ """HMAC-signed registered callback delivery and durable outbox processing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ from datetime import timedelta
8
+ from enum import StrEnum
9
+ from uuid import UUID
10
+
11
+ import httpx
12
+ from keble_data_infra_contract import parse_retry_after
13
+ from pydantic import SecretStr
14
+
15
+ from keble_scraper_contract import (
16
+ ScrapeCallbackIdentity,
17
+ ScrapeCompletionEvent,
18
+ ScrapeWebhookHeaders,
19
+ utc_now,
20
+ )
21
+
22
+ from keble_scraper_api.errors import ScraperDomainError, ScraperErrorCode
23
+ from keble_scraper_api.application.scheduling import ceil_persistence_millisecond
24
+ from keble_scraper_api.models import (
25
+ CallbackTransportDisposition,
26
+ CallbackTransportResult,
27
+ )
28
+ from keble_scraper_api.protocols import (
29
+ CompletionCallbackProtocol,
30
+ OutboxRepositoryProtocol,
31
+ )
32
+ from keble_scraper_api.settings import ScraperSettings
33
+
34
+
35
+ class CallbackDeliveryOutcome(StrEnum):
36
+ """One outbox lease result without conflating retry with queue exhaustion.
37
+
38
+ Side effects if changes:
39
+ - callback batch continuation and Sentry delivery counters.
40
+ """
41
+
42
+ EMPTY = "EMPTY"
43
+ DELIVERED = "DELIVERED"
44
+ RESCHEDULED = "RESCHEDULED"
45
+
46
+
47
+ class WebhookSigner:
48
+ """Sign timestamp, event identity, and exact body digest with HMAC-SHA256.
49
+
50
+ Steps:
51
+ 1. hash the exact serialized callback body;
52
+ 2. bind timestamp, event identity, and digest into one message;
53
+ 3. authenticate that message with the independent callback key.
54
+
55
+ Side effects if changes:
56
+ - Shopify callback verification and current/next key rotation.
57
+ """
58
+
59
+ def __init__(self, *, key: SecretStr) -> None:
60
+ """Bind one current signing key independent from service API keys.
61
+
62
+ Side effects if changes:
63
+ - all newly delivered callback signatures.
64
+ """
65
+
66
+ self._key = key
67
+
68
+ def sign(
69
+ self, *, event_id: UUID, body: bytes, timestamp: int
70
+ ) -> ScrapeWebhookHeaders:
71
+ """Build stable signature headers over the exact serialized body.
72
+
73
+ Steps:
74
+ 1. compute the exact body SHA-256 digest;
75
+ 2. assemble the replay-bound canonical message;
76
+ 3. return versioned HMAC headers without exposing key material.
77
+
78
+ Side effects if changes:
79
+ - webhook acceptance and replay identity.
80
+ """
81
+
82
+ digest = hashlib.sha256(body).hexdigest()
83
+ message = f"{timestamp}.{event_id}.{digest}".encode()
84
+ signature = hmac.new(
85
+ self._key.get_secret_value().encode(),
86
+ message,
87
+ hashlib.sha256,
88
+ ).hexdigest()
89
+ return ScrapeWebhookHeaders(
90
+ event_id=event_id,
91
+ timestamp=timestamp,
92
+ signature=f"v1={signature}",
93
+ )
94
+
95
+
96
+ class RegisteredHttpCompletionCallback:
97
+ """Deliver callbacks only to deployment-registered audience URLs.
98
+
99
+ Steps:
100
+ 1. map finite audiences to deployment-owned destinations;
101
+ 2. sign exact event bytes and reuse one pooled HTTP client;
102
+ 3. return typed HTTP dispositions without exception laundering.
103
+
104
+ Side effects if changes:
105
+ - outbound Shopify webhook calls; caller URLs remain impossible.
106
+ """
107
+
108
+ def __init__(self, *, settings: ScraperSettings, signer: WebhookSigner) -> None:
109
+ """Bind callback registry and independent HMAC signer.
110
+
111
+ Steps:
112
+ 1. retain immutable destination and timeout settings;
113
+ 2. retain the independent webhook signer;
114
+ 3. create one process-owned pooled HTTP client.
115
+
116
+ Side effects if changes:
117
+ - callback destinations and credentials.
118
+ """
119
+
120
+ self._settings = settings
121
+ self._signer = signer
122
+ self._client = httpx.AsyncClient(
123
+ timeout=self._settings.read_timeout_seconds,
124
+ trust_env=False,
125
+ )
126
+
127
+ async def close(self) -> None:
128
+ """Close the process-owned registered-callback connection pool.
129
+
130
+ Steps:
131
+ 1. drain and close pooled keep-alive connections;
132
+ 2. leave signing keys and destination settings untouched;
133
+ 3. remain safe after a process made no callback request.
134
+
135
+ Side effects if changes:
136
+ - API/worker shutdown and callback socket cleanup.
137
+ """
138
+
139
+ await self._client.aclose()
140
+
141
+ async def deliver(self, *, event: ScrapeCompletionEvent) -> CallbackTransportResult:
142
+ """POST one exact signed event and classify its HTTP response.
143
+
144
+ Steps:
145
+ 1. select only the deployment-registered audience destination;
146
+ 2. sign and send the exact event through the pooled client;
147
+ 3. return finite success/retry/permanent HTTP evidence while allowing
148
+ client and implementation exceptions to reach supervision.
149
+
150
+ Side effects if changes:
151
+ - external webhook call and downstream refresh-run transition.
152
+ """
153
+
154
+ destinations = {
155
+ ScrapeCallbackIdentity.SHOPIFY_INDEX: self._settings.shopify_callback_url,
156
+ }
157
+ destination = destinations[event.audience]
158
+ if destination is None:
159
+ raise ScraperDomainError(
160
+ code=ScraperErrorCode.CONFIGURATION_INVALID,
161
+ message="callback audience is not configured",
162
+ )
163
+ body = event.model_dump_json(by_alias=True).encode()
164
+ timestamp = int(utc_now().timestamp())
165
+ signed = self._signer.sign(
166
+ event_id=event.event_id,
167
+ body=body,
168
+ timestamp=timestamp,
169
+ )
170
+ response = await self._client.post(
171
+ str(destination),
172
+ content=body,
173
+ headers={
174
+ "Content-Type": "application/json",
175
+ "X-Keble-Event-ID": str(signed.event_id),
176
+ "X-Keble-Timestamp": str(signed.timestamp),
177
+ "X-Keble-Signature": signed.signature,
178
+ },
179
+ )
180
+ observed_at = utc_now()
181
+ retry_at = parse_retry_after(
182
+ value=response.headers.get("Retry-After"),
183
+ observed_at=observed_at,
184
+ )
185
+ if response.is_success:
186
+ disposition = CallbackTransportDisposition.DELIVERED
187
+ elif (
188
+ response.status_code in {408, 409, 425, 429} or response.status_code >= 500
189
+ ):
190
+ disposition = CallbackTransportDisposition.RETRYABLE
191
+ else:
192
+ disposition = CallbackTransportDisposition.PERMANENT
193
+ return CallbackTransportResult(
194
+ disposition=disposition,
195
+ status_code=response.status_code,
196
+ retry_at=(
197
+ retry_at
198
+ if disposition is CallbackTransportDisposition.RETRYABLE
199
+ else None
200
+ ),
201
+ )
202
+
203
+
204
+ class CallbackOutboxService:
205
+ """Lease/deliver/reschedule signed callbacks from the durable outbox.
206
+
207
+ Steps:
208
+ 1. lease one due event under worker ownership;
209
+ 2. deliver through the typed registered transport;
210
+ 3. acknowledge, reschedule, or dead-letter under immutable row limits.
211
+
212
+ Side effects if changes:
213
+ - webhook retries, dead letters, and at-least-once delivery timing.
214
+ """
215
+
216
+ def __init__(
217
+ self,
218
+ *,
219
+ repository: OutboxRepositoryProtocol,
220
+ callback: CompletionCallbackProtocol,
221
+ lease_seconds: int,
222
+ retry_base_seconds: int,
223
+ ) -> None:
224
+ """Bind outbox persistence, transport, lease, and retry cadence.
225
+
226
+ Steps:
227
+ 1. retain the callback repository and registered transport;
228
+ 2. retain the lease duration for physical delivery ownership;
229
+ 3. retain only retry cadence because each row owns its immutable limit.
230
+
231
+ Side effects if changes:
232
+ - callback worker concurrency and terminal delivery behavior.
233
+ """
234
+
235
+ self._repository = repository
236
+ self._callback = callback
237
+ self._lease_seconds = lease_seconds
238
+ self._retry_base_seconds = retry_base_seconds
239
+
240
+ async def deliver_one(self, *, worker_id: str) -> CallbackDeliveryOutcome:
241
+ """Deliver at most one due event and persist its next state.
242
+
243
+ Steps:
244
+ 1. lease one due row with an immutable attempt budget;
245
+ 2. acknowledge typed HTTP success or compute bounded receiver backoff;
246
+ 3. dead-letter permanent HTTP rejection or an exhausted retry budget;
247
+ 4. allow transport/runtime exceptions to leave the lease recoverable.
248
+
249
+ Side effects if changes:
250
+ - one external callback call and one outbox transition.
251
+ """
252
+
253
+ row = await self._repository.lease_callback_due(
254
+ worker_id=worker_id,
255
+ now=utc_now(),
256
+ lease_seconds=self._lease_seconds,
257
+ )
258
+ if row is None:
259
+ return CallbackDeliveryOutcome.EMPTY
260
+ delivered = await self._callback.deliver(event=row.event)
261
+ if delivered.disposition is CallbackTransportDisposition.DELIVERED:
262
+ await self._repository.mark_delivered(
263
+ event_id=row.event.event_id,
264
+ worker_id=worker_id,
265
+ delivered_at=utc_now(),
266
+ )
267
+ return CallbackDeliveryOutcome.DELIVERED
268
+ dead_letter = (
269
+ delivered.disposition is CallbackTransportDisposition.PERMANENT
270
+ or row.attempt_count >= row.attempt_limit
271
+ )
272
+ delay = self._retry_base_seconds * (2 ** max(0, row.attempt_count - 1))
273
+ local_next_attempt_at = utc_now() + timedelta(seconds=delay)
274
+ await self._repository.reschedule(
275
+ event_id=row.event.event_id,
276
+ worker_id=worker_id,
277
+ next_attempt_at=ceil_persistence_millisecond(
278
+ value=max(
279
+ local_next_attempt_at,
280
+ delivered.retry_at or local_next_attempt_at,
281
+ )
282
+ ),
283
+ dead_letter=dead_letter,
284
+ )
285
+ return CallbackDeliveryOutcome.RESCHEDULED
@@ -0,0 +1,63 @@
1
+ """Settings-owned Celery queue and beat composition."""
2
+
3
+ from celery import Celery
4
+
5
+ from keble_scraper_api.settings import ScraperSettings
6
+
7
+
8
+ def build_celery(*, settings: ScraperSettings) -> Celery:
9
+ """Build one RabbitMQ-only Celery app with isolated queues and schedules.
10
+
11
+ Side effects if changes:
12
+ - task acknowledgement, queue routing, retry recovery, and beat cadence.
13
+ """
14
+
15
+ app = Celery(
16
+ "keble-scraper",
17
+ broker=settings.rabbitmq_uri.get_secret_value(),
18
+ include=["keble_scraper_api.workers.tasks"],
19
+ )
20
+ app.conf.update(
21
+ task_serializer="json",
22
+ accept_content=["json"],
23
+ result_backend=None,
24
+ task_ignore_result=True,
25
+ task_acks_late=True,
26
+ task_reject_on_worker_lost=True,
27
+ worker_prefetch_multiplier=1,
28
+ broker_connection_retry_on_startup=True,
29
+ timezone="UTC",
30
+ enable_utc=True,
31
+ task_routes={
32
+ "keble_scraper.execute_job": {
33
+ "queue": settings.fetch_queue,
34
+ },
35
+ "keble_scraper.run_maintenance": {
36
+ "queue": settings.maintenance_queue,
37
+ },
38
+ "keble_scraper.deliver_callbacks": {
39
+ "queue": settings.maintenance_queue,
40
+ },
41
+ "keble_scraper.check_proxies": {
42
+ "queue": settings.proxy_health_queue,
43
+ },
44
+ },
45
+ beat_schedule={
46
+ "scraper-maintenance-every-minute": {
47
+ "task": "keble_scraper.run_maintenance",
48
+ "schedule": 60.0,
49
+ "options": {"queue": settings.maintenance_queue},
50
+ },
51
+ "scraper-callbacks-every-fifteen-seconds": {
52
+ "task": "keble_scraper.deliver_callbacks",
53
+ "schedule": 15.0,
54
+ "options": {"queue": settings.maintenance_queue},
55
+ },
56
+ "scraper-proxy-health-every-five-minutes": {
57
+ "task": "keble_scraper.check_proxies",
58
+ "schedule": 300.0,
59
+ "options": {"queue": settings.proxy_health_queue},
60
+ },
61
+ },
62
+ )
63
+ return app
@@ -0,0 +1,259 @@
1
+ """Cloudflare Browser Rendering exception transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime, timedelta
6
+ from decimal import Decimal
7
+ from time import monotonic
8
+ from typing import Literal
9
+
10
+ import httpx
11
+ from keble_data_infra_contract import parse_retry_after
12
+ from keble_helpers.typings import PydanticModelConfig
13
+ from pydantic import BaseModel, Field
14
+
15
+ from keble_scraper_contract import (
16
+ ScrapeArtifactTrust,
17
+ ScrapeAttemptOutcome,
18
+ ScrapeTransportRoute,
19
+ )
20
+
21
+ from keble_scraper_api.application.fetching import FetchResult, PreparedFetch
22
+ from keble_scraper_api.settings import ResponsePolicy, ScraperSettings
23
+
24
+ from .url_policy import PublicUrlPolicy
25
+
26
+
27
+ class CloudflareApiError(BaseModel):
28
+ """One documented Cloudflare API error item without response-body leakage.
29
+
30
+ Steps:
31
+ 1. retain the provider-native numeric code;
32
+ 2. validate but never propagate the provider message;
33
+ 3. expose only the code to typed failure evidence.
34
+
35
+ Side effects if changes:
36
+ - Browser Run rate-limit classification and operator diagnostics.
37
+ """
38
+
39
+ model_config = PydanticModelConfig.default(frozen=True, extra="ignore")
40
+
41
+ code: int
42
+ message: str = Field(min_length=1, max_length=1_024)
43
+
44
+
45
+ class CloudflareApiErrorEnvelope(BaseModel):
46
+ """Documented non-success envelope returned by Cloudflare Browser Run.
47
+
48
+ Steps:
49
+ 1. require Cloudflare's explicit unsuccessful marker;
50
+ 2. require at least one typed native error;
51
+ 3. keep the raw payload outside logs and public contracts.
52
+
53
+ Side effects if changes:
54
+ - malformed vendor responses escape to Sentry instead of becoming rate/payment facts.
55
+ """
56
+
57
+ model_config = PydanticModelConfig.default(frozen=True, extra="ignore")
58
+
59
+ success: Literal[False]
60
+ errors: tuple[CloudflareApiError, ...] = Field(min_length=1)
61
+
62
+
63
+ class CloudflareBrowserFetcher:
64
+ """Render reviewed exception pages through Cloudflare's content endpoint.
65
+
66
+ Steps:
67
+ 1. enforce local URL/domain/credential policy before vendor work;
68
+ 2. reuse one pooled Browser Rendering client;
69
+ 3. return bounded content or typed native capacity evidence.
70
+
71
+ Side effects if changes:
72
+ - browser vendor spend, rendered artifacts, and rate-limit accounting.
73
+ """
74
+
75
+ def __init__(
76
+ self, *, settings: ScraperSettings, url_policy: PublicUrlPolicy
77
+ ) -> None:
78
+ """Bind credentials and the same pre-dispatch public URL policy.
79
+
80
+ Steps:
81
+ 1. retain immutable renderer settings and URL policy;
82
+ 2. construct one process-owned client without ambient proxy trust;
83
+ 3. defer vendor calls until an allowlisted request arrives.
84
+
85
+ Side effects if changes:
86
+ - Cloudflare availability and SSRF validation before remote rendering.
87
+ """
88
+
89
+ self._settings = settings
90
+ self._url_policy = url_policy
91
+ self._client = httpx.AsyncClient(
92
+ timeout=self._settings.read_timeout_seconds,
93
+ trust_env=False,
94
+ )
95
+
96
+ async def close(self) -> None:
97
+ """Close the pooled Cloudflare HTTP transport.
98
+
99
+ Steps:
100
+ 1. close keep-alive connections owned by this fetcher;
101
+ 2. remain safe when called after no vendor request;
102
+ 3. leave credential/settings ownership unchanged.
103
+
104
+ Side effects if changes:
105
+ - API/worker shutdown and Cloudflare socket cleanup.
106
+ """
107
+
108
+ await self._client.aclose()
109
+
110
+ async def fetch(
111
+ self, *, request: PreparedFetch, response_policy: ResponsePolicy
112
+ ) -> FetchResult:
113
+ """Validate, render, parse capacity evidence, and bound returned HTML.
114
+
115
+ Steps:
116
+ 1. validate public target plus explicit domain/credential gates;
117
+ 2. issue one pooled Browser Run request and parse shared Retry-After evidence;
118
+ 3. retain documented native error codes without inferring payment;
119
+ 4. enforce the response byte ceiling before returning typed evidence.
120
+
121
+ Side effects if changes:
122
+ - actual Browser Rendering calls and billed browser duration.
123
+ """
124
+
125
+ target = await self._url_policy.validate_and_resolve(url=request.url)
126
+ if not self._domain_allowed(hostname=target.hostname):
127
+ return FetchResult(
128
+ outcome=ScrapeAttemptOutcome.DENIED,
129
+ route=ScrapeTransportRoute.CLOUDFLARE_BROWSER_RUN,
130
+ trust=ScrapeArtifactTrust.VERIFIED,
131
+ final_url=request.url,
132
+ elapsed_ms=0,
133
+ error_code="URL_DENIED",
134
+ observed_at=datetime.now(UTC),
135
+ )
136
+ if (
137
+ self._settings.cloudflare_account_id is None
138
+ or self._settings.cloudflare_api_token is None
139
+ ):
140
+ return self._failed(
141
+ url=request.url, error_code="ROUTE_UNAVAILABLE", elapsed_ms=0
142
+ )
143
+ endpoint = (
144
+ "https://api.cloudflare.com/client/v4/accounts/"
145
+ f"{self._settings.cloudflare_account_id}/browser-rendering/content"
146
+ )
147
+ started = monotonic()
148
+ raw = await self._client.post(
149
+ endpoint,
150
+ headers={
151
+ "Authorization": (
152
+ "Bearer " + self._settings.cloudflare_api_token.get_secret_value()
153
+ )
154
+ },
155
+ json={
156
+ "url": request.url,
157
+ "gotoOptions": {"waitUntil": "networkidle0"},
158
+ "rejectResourceTypes": ["stylesheet", "font"],
159
+ },
160
+ )
161
+ elapsed = max(0.0, monotonic() - started)
162
+ elapsed_ms = int(elapsed * 1000)
163
+ observed_at = datetime.now(UTC)
164
+ retry_at = parse_retry_after(
165
+ value=raw.headers.get("Retry-After"),
166
+ observed_at=observed_at,
167
+ )
168
+ if raw.status_code == 429 and retry_at is None:
169
+ retry_at = observed_at + timedelta(
170
+ seconds=self._settings.upstream_rate_limit_default_seconds
171
+ )
172
+ upstream_code = self._upstream_code(response=raw)
173
+ body = raw.content
174
+ if len(body) > response_policy.max_bytes:
175
+ return FetchResult(
176
+ outcome=ScrapeAttemptOutcome.SIZE_LIMIT,
177
+ route=ScrapeTransportRoute.CLOUDFLARE_BROWSER_RUN,
178
+ trust=ScrapeArtifactTrust.VERIFIED,
179
+ final_url=request.url,
180
+ status_code=raw.status_code,
181
+ content_type="text/html",
182
+ elapsed_ms=elapsed_ms,
183
+ browser_seconds=Decimal(str(round(elapsed, 3))),
184
+ error_code="RESPONSE_TOO_LARGE",
185
+ upstream_code=upstream_code,
186
+ retry_at=retry_at,
187
+ observed_at=observed_at,
188
+ )
189
+ outcome = (
190
+ ScrapeAttemptOutcome.SUCCEEDED
191
+ if raw.is_success
192
+ else ScrapeAttemptOutcome.HTTP_ERROR
193
+ )
194
+ return FetchResult(
195
+ outcome=outcome,
196
+ route=ScrapeTransportRoute.CLOUDFLARE_BROWSER_RUN,
197
+ trust=ScrapeArtifactTrust.VERIFIED,
198
+ final_url=request.url,
199
+ status_code=raw.status_code,
200
+ content_type="text/html",
201
+ body=body,
202
+ elapsed_ms=elapsed_ms,
203
+ browser_seconds=Decimal(str(round(elapsed, 3))),
204
+ error_code=None
205
+ if outcome is ScrapeAttemptOutcome.SUCCEEDED
206
+ else "HTTP_ERROR",
207
+ upstream_code=upstream_code,
208
+ retry_at=retry_at,
209
+ observed_at=observed_at,
210
+ )
211
+
212
+ @staticmethod
213
+ def _upstream_code(*, response: httpx.Response) -> str | None:
214
+ """Extract a documented native code from a rate-limit envelope.
215
+
216
+ Steps:
217
+ 1. leave success and non-429 HTTP classification to status evidence;
218
+ 2. validate the documented 429 JSON envelope with Pydantic;
219
+ 3. retain only the first bounded numeric code as ``CF_<code>``.
220
+
221
+ Side effects if changes:
222
+ - Cloudflare code 2001 remains rate evidence, never payment evidence;
223
+ - malformed 429 envelopes escape for Sentry/supervision.
224
+ """
225
+
226
+ if response.status_code != 429:
227
+ return None
228
+ envelope = CloudflareApiErrorEnvelope.model_validate_json(response.content)
229
+ return f"CF_{envelope.errors[0].code}"
230
+
231
+ def _domain_allowed(self, *, hostname: str) -> bool:
232
+ """Require an exact or subdomain match against reviewed browser targets.
233
+
234
+ Side effects if changes:
235
+ - remote browser SSRF exposure and eligible exception-store domains.
236
+ """
237
+
238
+ return any(
239
+ hostname == domain or hostname.endswith(f".{domain}")
240
+ for domain in self._settings.cloudflare_browser_domain_allowlist
241
+ )
242
+
243
+ @staticmethod
244
+ def _failed(*, url: str, error_code: str, elapsed_ms: int) -> FetchResult:
245
+ """Build one sanitized renderer failure.
246
+
247
+ Side effects if changes:
248
+ - renderer retry and terminal gap reasons.
249
+ """
250
+
251
+ return FetchResult(
252
+ outcome=ScrapeAttemptOutcome.NETWORK_ERROR,
253
+ route=ScrapeTransportRoute.CLOUDFLARE_BROWSER_RUN,
254
+ trust=ScrapeArtifactTrust.VERIFIED,
255
+ final_url=url,
256
+ elapsed_ms=elapsed_ms,
257
+ error_code=error_code,
258
+ observed_at=datetime.now(UTC),
259
+ )
@@ -0,0 +1,75 @@
1
+ """Encrypted-at-rest proxy credential conversion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import quote, urlsplit
6
+
7
+ from cryptography.fernet import Fernet
8
+ from pydantic import SecretStr
9
+
10
+ from keble_scraper_contract import ProxyCandidateCreate
11
+
12
+ from keble_scraper_api.errors import ScraperDomainError, ScraperErrorCode
13
+
14
+
15
+ class ProxyCredentialCipher:
16
+ """Encrypt complete proxy URLs and expose only redacted operational values.
17
+
18
+ Side effects if changes:
19
+ - Mongo proxy secrets and actual experimental proxy egress.
20
+ """
21
+
22
+ def __init__(self, *, fernet_key: SecretStr) -> None:
23
+ """Construct one process cipher from secret-system material.
24
+
25
+ Side effects if changes:
26
+ - old proxy candidates must remain decryptable across deployments.
27
+ """
28
+
29
+ self._fernet = Fernet(fernet_key.get_secret_value().encode())
30
+
31
+ def encode(self, *, command: ProxyCandidateCreate) -> tuple[str, str]:
32
+ """Validate an endpoint, attach credentials, and return redacted/ciphertext.
33
+
34
+ Side effects if changes:
35
+ - admin imports can create future outbound network routes.
36
+ """
37
+
38
+ parsed = urlsplit(command.endpoint)
39
+ if (
40
+ parsed.scheme not in {"http", "https"}
41
+ or parsed.hostname is None
42
+ or parsed.username is not None
43
+ or parsed.password is not None
44
+ or parsed.query
45
+ or parsed.fragment
46
+ or parsed.path not in {"", "/"}
47
+ ):
48
+ raise ScraperDomainError(
49
+ code=ScraperErrorCode.URL_DENIED,
50
+ message="proxy endpoint must be a credential-free HTTP(S) origin",
51
+ )
52
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
53
+ credentials = ""
54
+ if command.username is not None:
55
+ password = (
56
+ command.password.get_secret_value()
57
+ if command.password is not None
58
+ else ""
59
+ )
60
+ credentials = (
61
+ f"{quote(command.username, safe='')}:{quote(password, safe='')}@"
62
+ )
63
+ complete = f"{parsed.scheme}://{credentials}{parsed.hostname}:{port}"
64
+ redacted = f"{parsed.scheme}://{parsed.hostname}:{port}"
65
+ encrypted = self._fernet.encrypt(complete.encode()).decode()
66
+ return redacted, encrypted
67
+
68
+ def decode(self, *, encrypted_proxy_url: str) -> SecretStr:
69
+ """Decrypt one route only at dispatch time.
70
+
71
+ Side effects if changes:
72
+ - proxy credentials enter the HTTP client's in-memory request config.
73
+ """
74
+
75
+ return SecretStr(self._fernet.decrypt(encrypted_proxy_url.encode()).decode())