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,460 @@
1
+ """Redis-backed route rate/spend reservation and Bright session routing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ from collections.abc import Awaitable
8
+ from datetime import UTC, datetime, timedelta
9
+ from decimal import ROUND_CEILING, Decimal
10
+ from typing import Protocol, cast
11
+ from urllib.parse import quote
12
+
13
+ from pydantic import SecretStr
14
+ from redis.asyncio import Redis
15
+
16
+ from keble_scraper_contract import (
17
+ RouteBudgetView,
18
+ ScrapeTransportPolicy,
19
+ ScrapeTransportRoute,
20
+ )
21
+
22
+ from keble_scraper_api.errors import ScraperErrorCode
23
+ from keble_scraper_api.models import (
24
+ RouteReservation,
25
+ RouteReservationDenied,
26
+ RouteReservationGranted,
27
+ RouteReservationOutcome,
28
+ )
29
+ from keble_scraper_api.protocols import ProxyRepositoryProtocol
30
+ from keble_scraper_api.settings import ScraperSettings
31
+
32
+ _RESERVE_SCRIPT = """
33
+ local used = tonumber(redis.call('GET', KEYS[2]) or '0')
34
+ local requested = tonumber(ARGV[3])
35
+ local budget = tonumber(ARGV[4])
36
+ if used + requested > budget then
37
+ return {0, 2, 0}
38
+ end
39
+ local now_ms = tonumber(ARGV[1])
40
+ local interval_ms = tonumber(ARGV[2])
41
+ local next_at_ms = tonumber(redis.call('GET', KEYS[1]) or now_ms)
42
+ local admitted_at_ms = math.max(now_ms, next_at_ms)
43
+ local wait_ms = admitted_at_ms - now_ms
44
+ redis.call('SET', KEYS[1], admitted_at_ms + interval_ms)
45
+ redis.call('EXPIRE', KEYS[1], tonumber(ARGV[5]))
46
+ redis.call('INCRBY', KEYS[2], requested)
47
+ redis.call('EXPIRE', KEYS[2], tonumber(ARGV[5]))
48
+ return {1, used + requested, wait_ms}
49
+ """
50
+
51
+
52
+ class ProxyRouteSleeper(Protocol):
53
+ """Injectable worker suspension for a reserved future route slot.
54
+
55
+ Side effects if changes:
56
+ - cross-worker request pacing and deterministic router tests.
57
+ """
58
+
59
+ async def sleep(self, *, seconds: float) -> None:
60
+ """Suspend the current worker until its atomic route slot is due.
61
+
62
+ Side effects if changes:
63
+ - provider request start times and worker lease duration.
64
+ """
65
+
66
+ ...
67
+
68
+
69
+ class AsyncioProxyRouteSleeper:
70
+ """Production route pacing backed by cancellable asyncio suspension.
71
+
72
+ Side effects if changes:
73
+ - live direct, Bright, browser, and public-proxy request timing.
74
+ """
75
+
76
+ async def sleep(self, *, seconds: float) -> None:
77
+ """Wait without blocking another coroutine or worker process.
78
+
79
+ Side effects if changes:
80
+ - route throughput and Celery worker utilization.
81
+ """
82
+
83
+ await asyncio.sleep(seconds)
84
+
85
+
86
+ class RedisProxyRouter:
87
+ """Choose egress and atomically reserve rate plus daily vendor spend.
88
+
89
+ Steps:
90
+ 1. select a finite route from policy and attempt ordinal;
91
+ 2. derive credentials/affinity without exposing secrets;
92
+ 3. atomically reserve pacing and daily spend or return typed denial.
93
+
94
+ Side effects if changes:
95
+ - direct/Bright/browser/public route selection and vendor budget stops.
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ *,
101
+ redis: Redis,
102
+ settings: ScraperSettings,
103
+ proxy_repository: ProxyRepositoryProtocol,
104
+ sleeper: ProxyRouteSleeper | None = None,
105
+ ) -> None:
106
+ """Bind the shared Redis state and quarantined proxy repository.
107
+
108
+ Side effects if changes:
109
+ - all workers share the same rate, spend, and session policy.
110
+ """
111
+
112
+ self._redis = redis
113
+ self._settings = settings
114
+ self._proxy_repository = proxy_repository
115
+ self._sleeper = sleeper or AsyncioProxyRouteSleeper()
116
+
117
+ async def reserve(
118
+ self,
119
+ *,
120
+ policy: ScrapeTransportPolicy,
121
+ affinity_key: str,
122
+ estimated_bytes: int,
123
+ attempt_number: int,
124
+ ) -> RouteReservationOutcome:
125
+ """Reserve one route or return a finite expected preflight denial.
126
+
127
+ Steps:
128
+ 1. select a finite route from policy and attempt ordinal;
129
+ 2. derive Bright affinity locally while Bright rotates the real IP;
130
+ 3. atomically enforce second-level rate and UTC-day spend ceilings.
131
+
132
+ Side effects if changes:
133
+ - worker dispatch may spend vendor money and consume rate capacity.
134
+ """
135
+
136
+ route = self._select_route(policy=policy, attempt_number=attempt_number)
137
+ proxy_url: SecretStr | None = None
138
+ session_id: str | None = None
139
+ if route is ScrapeTransportRoute.BRIGHT_RESIDENTIAL:
140
+ bright = self._bright_proxy(affinity_key=affinity_key)
141
+ if bright is None:
142
+ return RouteReservationDenied(
143
+ route=route,
144
+ code=ScraperErrorCode.CONFIGURATION_INVALID,
145
+ safe_message="Bright route is not configured.",
146
+ )
147
+ proxy_url, session_id = bright
148
+ if route is ScrapeTransportRoute.PUBLIC_POOL_EXPERIMENT:
149
+ if not self._settings.public_proxy_experiment_enabled:
150
+ return RouteReservationDenied(
151
+ route=route,
152
+ code=ScraperErrorCode.CONFIGURATION_INVALID,
153
+ safe_message="Public proxy experiment is disabled.",
154
+ )
155
+ proxy_url = await self._proxy_repository.choose_healthy()
156
+ if proxy_url is None:
157
+ return RouteReservationDenied(
158
+ route=route,
159
+ code=ScraperErrorCode.ROUTE_UNAVAILABLE,
160
+ safe_message="No healthy quarantined proxy is available.",
161
+ retry_at=datetime.now(UTC)
162
+ + timedelta(
163
+ seconds=self._settings.upstream_rate_limit_default_seconds
164
+ ),
165
+ )
166
+ estimated_cost = self._cost(
167
+ route=route,
168
+ byte_count=estimated_bytes,
169
+ browser_seconds=30.0
170
+ if route is ScrapeTransportRoute.CLOUDFLARE_BROWSER_RUN
171
+ else 0.0,
172
+ )
173
+ budget_denial = await self._reserve_budget(
174
+ route=route,
175
+ amount=estimated_cost,
176
+ )
177
+ if budget_denial is not None:
178
+ return budget_denial
179
+ return RouteReservationGranted(
180
+ reservation=RouteReservation(
181
+ route=route,
182
+ proxy_url=proxy_url,
183
+ session_id=session_id,
184
+ reserved_cost_usd=estimated_cost,
185
+ )
186
+ )
187
+
188
+ async def reconcile(
189
+ self,
190
+ *,
191
+ reservation: RouteReservation,
192
+ actual_bytes: int,
193
+ browser_seconds: float,
194
+ ) -> Decimal:
195
+ """Replace estimated spend with measured route units after an attempt.
196
+
197
+ Side effects if changes:
198
+ - remaining daily Bright/Cloudflare budget and benchmark cost truth.
199
+ """
200
+
201
+ actual = self._cost(
202
+ route=reservation.route,
203
+ byte_count=actual_bytes,
204
+ browser_seconds=browser_seconds,
205
+ )
206
+ delta = actual - reservation.reserved_cost_usd
207
+ if delta != 0:
208
+ key = self._budget_key(route=reservation.route)
209
+ await self._redis.incrby(key, self._micro_usd(delta))
210
+ return actual
211
+
212
+ async def budgets(self) -> tuple[RouteBudgetView, ...]:
213
+ """Return credential-free current UTC-day budget views.
214
+
215
+ Side effects if changes:
216
+ - operations cost dashboard and capacity approvals.
217
+ """
218
+
219
+ views: list[RouteBudgetView] = []
220
+ for route in ScrapeTransportRoute:
221
+ raw = await self._redis.get(self._budget_key(route=route))
222
+ consumed = Decimal(str(raw or 0)) / Decimal("1000000")
223
+ limit, rate, enabled = self._policy(route=route)
224
+ views.append(
225
+ RouteBudgetView(
226
+ route=route,
227
+ daily_limit_usd=limit,
228
+ consumed_today_usd=max(Decimal("0"), consumed),
229
+ request_rate_per_second=rate,
230
+ enabled=enabled,
231
+ )
232
+ )
233
+ return tuple(views)
234
+
235
+ def _select_route(
236
+ self,
237
+ *,
238
+ policy: ScrapeTransportPolicy,
239
+ attempt_number: int,
240
+ ) -> ScrapeTransportRoute:
241
+ """Map caller policy plus retry ordinal to one actual route.
242
+
243
+ Side effects if changes:
244
+ - direct-to-Bright fallback and exception-renderer activation.
245
+ """
246
+
247
+ if policy is ScrapeTransportPolicy.DIRECT_ONLY:
248
+ return ScrapeTransportRoute.DIRECT
249
+ if policy is ScrapeTransportPolicy.DIRECT_THEN_BRIGHT:
250
+ return (
251
+ ScrapeTransportRoute.DIRECT
252
+ if attempt_number == 1
253
+ else ScrapeTransportRoute.BRIGHT_RESIDENTIAL
254
+ )
255
+ if policy is ScrapeTransportPolicy.BRIGHT_ONLY:
256
+ return ScrapeTransportRoute.BRIGHT_RESIDENTIAL
257
+ if policy is ScrapeTransportPolicy.BROWSER_EXCEPTION:
258
+ return ScrapeTransportRoute.CLOUDFLARE_BROWSER_RUN
259
+ return ScrapeTransportRoute.PUBLIC_POOL_EXPERIMENT
260
+
261
+ def _bright_proxy(self, *, affinity_key: str) -> tuple[SecretStr, str] | None:
262
+ """Build configured Bright credentials or return expected absence.
263
+
264
+ Steps:
265
+ 1. return absence when any required server-owned credential is missing;
266
+ 2. derive deterministic daily affinity without exposing the password;
267
+ 3. return the secret-wrapped URL plus non-secret session identity.
268
+
269
+ Side effects if changes:
270
+ - Bright session stickiness and actual residential egress billing.
271
+ """
272
+
273
+ host = self._settings.bright_proxy_host
274
+ customer = self._settings.bright_customer
275
+ zone = self._settings.bright_zone
276
+ password_secret = self._settings.bright_password
277
+ if host is None or customer is None or zone is None or password_secret is None:
278
+ return None
279
+ session_seed = f"{affinity_key}:{datetime.now(UTC).date().isoformat()}".encode()
280
+ session_id = hashlib.sha256(session_seed).hexdigest()[:16]
281
+ username = f"brd-customer-{customer}-zone-{zone}-session-{session_id}"
282
+ password = password_secret.get_secret_value()
283
+ proxy = (
284
+ f"http://{quote(username, safe='')}:{quote(password, safe='')}@"
285
+ f"{host}:{self._settings.bright_proxy_port}"
286
+ )
287
+ return SecretStr(proxy), session_id
288
+
289
+ async def _reserve_budget(
290
+ self,
291
+ *,
292
+ route: ScrapeTransportRoute,
293
+ amount: Decimal,
294
+ ) -> RouteReservationDenied | None:
295
+ """Reserve one future slot or return an expected route/budget denial.
296
+
297
+ Steps:
298
+ 1. reject disabled route policy as typed local data;
299
+ 2. atomically reserve distributed rate and daily spend state;
300
+ 3. return budget exhaustion as typed data or await the granted slot.
301
+
302
+ The Redis script assigns each worker a globally ordered start time. A
303
+ worker waits for that slot instead of treating normal burst pressure as
304
+ a route failure or consuming a paid fallback attempt.
305
+
306
+ Side effects if changes:
307
+ - concurrent workers are paced or stopped before external calls.
308
+ """
309
+
310
+ limit, rate, enabled = self._policy(route=route)
311
+ if not enabled:
312
+ return RouteReservationDenied(
313
+ route=route,
314
+ code=ScraperErrorCode.CONFIGURATION_INVALID,
315
+ safe_message=f"{route.value} route is disabled.",
316
+ )
317
+ result = await cast(
318
+ Awaitable[list[int]],
319
+ self._redis.eval(
320
+ _RESERVE_SCRIPT,
321
+ 2,
322
+ self._rate_key(route=route),
323
+ self._budget_key(route=route),
324
+ str(self._now_milliseconds()),
325
+ str(self._rate_interval_milliseconds(rate=rate)),
326
+ str(self._micro_usd(amount)),
327
+ str(self._micro_usd(limit)),
328
+ "172800",
329
+ ),
330
+ )
331
+ if int(result[0]) == 0:
332
+ now = datetime.now(UTC)
333
+ return RouteReservationDenied(
334
+ route=route,
335
+ code=ScraperErrorCode.BUDGET_EXHAUSTED,
336
+ safe_message="Route daily spend limit is exhausted.",
337
+ retry_at=now.replace(
338
+ hour=0,
339
+ minute=0,
340
+ second=0,
341
+ microsecond=0,
342
+ )
343
+ + timedelta(days=1),
344
+ )
345
+ wait_seconds = int(result[2]) / 1_000
346
+ if wait_seconds > 0:
347
+ await self._sleeper.sleep(seconds=wait_seconds)
348
+ return None
349
+
350
+ def _policy(
351
+ self,
352
+ *,
353
+ route: ScrapeTransportRoute,
354
+ ) -> tuple[Decimal, Decimal, bool]:
355
+ """Resolve server-owned daily cost/rate/enabled policy for one route.
356
+
357
+ Side effects if changes:
358
+ - preflight capacity and every route reservation.
359
+ """
360
+
361
+ policies = {
362
+ ScrapeTransportRoute.DIRECT: (
363
+ Decimal("0"),
364
+ self._settings.direct_requests_per_second,
365
+ True,
366
+ ),
367
+ ScrapeTransportRoute.BRIGHT_RESIDENTIAL: (
368
+ self._settings.bright_daily_budget_usd,
369
+ self._settings.bright_requests_per_second,
370
+ self._settings.bright_proxy_host is not None,
371
+ ),
372
+ ScrapeTransportRoute.CLOUDFLARE_BROWSER_RUN: (
373
+ self._settings.cloudflare_daily_budget_usd,
374
+ self._settings.cloudflare_requests_per_second,
375
+ self._settings.cloudflare_account_id is not None,
376
+ ),
377
+ ScrapeTransportRoute.PUBLIC_POOL_EXPERIMENT: (
378
+ Decimal("0"),
379
+ Decimal("1"),
380
+ self._settings.public_proxy_experiment_enabled,
381
+ ),
382
+ }
383
+ return policies[route]
384
+
385
+ def _cost(
386
+ self,
387
+ *,
388
+ route: ScrapeTransportRoute,
389
+ byte_count: int,
390
+ browser_seconds: float,
391
+ ) -> Decimal:
392
+ """Price measured vendor units without guessing unconfigured calls.
393
+
394
+ Side effects if changes:
395
+ - cost ledger, budget exhaustion, and rollout extrapolation.
396
+ """
397
+
398
+ if route is ScrapeTransportRoute.BRIGHT_RESIDENTIAL:
399
+ return (
400
+ Decimal(byte_count)
401
+ / Decimal("1000000000")
402
+ * self._settings.bright_cost_per_gb_usd
403
+ ).quantize(Decimal("0.00000001"))
404
+ if route is ScrapeTransportRoute.CLOUDFLARE_BROWSER_RUN:
405
+ return (
406
+ Decimal(str(browser_seconds))
407
+ / Decimal("3600")
408
+ * self._settings.cloudflare_browser_cost_per_hour_usd
409
+ ).quantize(Decimal("0.00000001"))
410
+ return Decimal("0")
411
+
412
+ def _rate_key(self, *, route: ScrapeTransportRoute) -> str:
413
+ """Build one stable per-route next-admission timestamp key.
414
+
415
+ Side effects if changes:
416
+ - cross-worker rate pacing and Redis state ownership.
417
+ """
418
+
419
+ return f"{self._settings.redis_namespace}:route:{route.value}:rate"
420
+
421
+ def _budget_key(self, *, route: ScrapeTransportRoute) -> str:
422
+ """Build one per-route UTC-day spend key.
423
+
424
+ Side effects if changes:
425
+ - budget reconciliation and dashboard reads.
426
+ """
427
+
428
+ day = datetime.now(UTC).date().isoformat()
429
+ return f"{self._settings.redis_namespace}:route:{route.value}:budget:{day}"
430
+
431
+ @staticmethod
432
+ def _micro_usd(amount: Decimal) -> int:
433
+ """Convert exact USD decimals to atomic Redis micro-dollar integers.
434
+
435
+ Side effects if changes:
436
+ - rate/budget atomicity and reconciliation rounding.
437
+ """
438
+
439
+ return int((amount * Decimal("1000000")).to_integral_value())
440
+
441
+ @staticmethod
442
+ def _now_milliseconds() -> int:
443
+ """Return current UTC epoch milliseconds for Redis admission ordering.
444
+
445
+ Side effects if changes:
446
+ - route wait duration and distributed worker ordering.
447
+ """
448
+
449
+ return int(datetime.now(UTC).timestamp() * 1_000)
450
+
451
+ @staticmethod
452
+ def _rate_interval_milliseconds(*, rate: Decimal) -> int:
453
+ """Convert requests per second into a conservative integer interval.
454
+
455
+ Side effects if changes:
456
+ - actual route throughput and provider rate-limit compliance.
457
+ """
458
+
459
+ interval = (Decimal("1000") / rate).to_integral_value(rounding=ROUND_CEILING)
460
+ return max(1, int(interval))
@@ -0,0 +1,220 @@
1
+ """Server-owned request preparation and Shopify GraphQL templates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+
8
+ from keble_scraper_contract import (
9
+ PublicHttpRequest,
10
+ ScrapeJobCreate,
11
+ ShopifyStorefrontGraphqlRequest,
12
+ ShopifyStorefrontOperation,
13
+ )
14
+
15
+ from keble_scraper_api.application.fetching import PreparedFetch
16
+
17
+ _PRODUCT_IDENTITIES_QUERY = """
18
+ query ProductIdentities($first: Int!, $after: String, $query: String) {
19
+ products(first: $first, after: $after, sortKey: UPDATED_AT, query: $query) {
20
+ nodes { id handle updatedAt onlineStoreUrl }
21
+ pageInfo { hasNextPage endCursor }
22
+ }
23
+ }
24
+ """.strip()
25
+
26
+ _PRODUCT_DETAIL_QUERY = """
27
+ query ProductDetail($id: ID!) {
28
+ product(id: $id) {
29
+ id handle title description descriptionHtml productType vendor tags updatedAt
30
+ onlineStoreUrl
31
+ featuredImage { id url altText width height }
32
+ variants(first: 100) {
33
+ nodes {
34
+ id title sku availableForSale
35
+ price { amount currencyCode }
36
+ compareAtPrice { amount currencyCode }
37
+ selectedOptions { name value }
38
+ image { id url altText width height }
39
+ }
40
+ pageInfo { hasNextPage endCursor }
41
+ }
42
+ media(first: 100) {
43
+ nodes {
44
+ id mediaContentType alt previewImage { id url altText width height }
45
+ ... on MediaImage { image { id url altText width height } }
46
+ ... on Video { sources { url mimeType } }
47
+ ... on ExternalVideo { embeddedUrl }
48
+ ... on Model3d { sources { url mimeType } }
49
+ }
50
+ pageInfo { hasNextPage endCursor }
51
+ }
52
+ }
53
+ }
54
+ """.strip()
55
+
56
+ _PRODUCT_VARIANTS_QUERY = """
57
+ query ProductVariants($id: ID!, $first: Int!, $after: String) {
58
+ product(id: $id) {
59
+ id
60
+ variants(first: $first, after: $after) {
61
+ nodes {
62
+ id title sku availableForSale
63
+ price { amount currencyCode }
64
+ compareAtPrice { amount currencyCode }
65
+ selectedOptions { name value }
66
+ image { id url altText width height }
67
+ }
68
+ pageInfo { hasNextPage endCursor }
69
+ }
70
+ }
71
+ }
72
+ """.strip()
73
+
74
+ _PRODUCT_MEDIA_QUERY = """
75
+ query ProductMedia($id: ID!, $first: Int!, $after: String) {
76
+ product(id: $id) {
77
+ id
78
+ media(first: $first, after: $after) {
79
+ nodes {
80
+ id mediaContentType alt previewImage { id url altText width height }
81
+ ... on MediaImage { image { id url altText width height } }
82
+ ... on Video { sources { url mimeType } }
83
+ ... on ExternalVideo { embeddedUrl }
84
+ ... on Model3d { sources { url mimeType } }
85
+ }
86
+ pageInfo { hasNextPage endCursor }
87
+ }
88
+ }
89
+ }
90
+ """.strip()
91
+
92
+
93
+ class RequestProfileRegistry:
94
+ """Convert contract requests into credential-free outbound bytes.
95
+
96
+ Side effects if changes:
97
+ - cache identity and all generic/Shopify transport requests.
98
+ """
99
+
100
+ def prepare(self, *, command: ScrapeJobCreate) -> PreparedFetch:
101
+ """Dispatch one discriminated request to its reviewed builder.
102
+
103
+ Side effects if changes:
104
+ - arbitrary POST/header prevention and request deduplication.
105
+ """
106
+
107
+ request = command.request
108
+ if isinstance(request, PublicHttpRequest):
109
+ return self._prepare_public(request=request)
110
+ return self._prepare_shopify(request=request)
111
+
112
+ @staticmethod
113
+ def _prepare_public(*, request: PublicHttpRequest) -> PreparedFetch:
114
+ """Prepare bounded generic headers without caller auth/host values.
115
+
116
+ Side effects if changes:
117
+ - generic GET/HEAD wire behavior and cache identity.
118
+ """
119
+
120
+ headers = {
121
+ key: value
122
+ for key, value in {
123
+ "Accept": request.accept,
124
+ "Accept-Language": request.accept_language,
125
+ }.items()
126
+ if value is not None
127
+ }
128
+ identity = RequestProfileRegistry._identity(
129
+ method=request.method,
130
+ url=str(request.url),
131
+ headers=headers,
132
+ body=None,
133
+ )
134
+ return PreparedFetch(
135
+ url=str(request.url),
136
+ method=request.method,
137
+ headers=headers,
138
+ request_identity=identity,
139
+ )
140
+
141
+ @staticmethod
142
+ def _prepare_shopify(*, request: ShopifyStorefrontGraphqlRequest) -> PreparedFetch:
143
+ """Build one allowlisted Storefront POST query and typed variables.
144
+
145
+ Side effects if changes:
146
+ - Shopify query complexity, pagination, and API-version compatibility.
147
+ """
148
+
149
+ query_by_operation = {
150
+ ShopifyStorefrontOperation.PRODUCT_IDENTITIES: _PRODUCT_IDENTITIES_QUERY,
151
+ ShopifyStorefrontOperation.PRODUCT_DETAIL: _PRODUCT_DETAIL_QUERY,
152
+ ShopifyStorefrontOperation.PRODUCT_VARIANTS: _PRODUCT_VARIANTS_QUERY,
153
+ ShopifyStorefrontOperation.PRODUCT_MEDIA: _PRODUCT_MEDIA_QUERY,
154
+ }
155
+ query = query_by_operation[request.operation]
156
+ variables: dict[str, object]
157
+ if request.operation is ShopifyStorefrontOperation.PRODUCT_IDENTITIES:
158
+ variables = {
159
+ "first": request.first,
160
+ "after": request.after,
161
+ "query": (
162
+ f"updated_at:>{request.updated_after.isoformat()}"
163
+ if request.updated_after is not None
164
+ else None
165
+ ),
166
+ }
167
+ elif request.operation is ShopifyStorefrontOperation.PRODUCT_DETAIL:
168
+ variables = {"id": request.product_id}
169
+ else:
170
+ variables = {
171
+ "id": request.product_id,
172
+ "first": request.first,
173
+ "after": request.after,
174
+ }
175
+ body = json.dumps(
176
+ {"query": query, "variables": variables},
177
+ sort_keys=True,
178
+ separators=(",", ":"),
179
+ ).encode()
180
+ url = f"https://{request.store_domain}/api/2026-07/graphql.json"
181
+ headers = {"Accept": "application/json", "Content-Type": "application/json"}
182
+ identity = RequestProfileRegistry._identity(
183
+ method="POST",
184
+ url=url,
185
+ headers=headers,
186
+ body=body,
187
+ )
188
+ return PreparedFetch(
189
+ url=url,
190
+ method="POST",
191
+ headers=headers,
192
+ body=body,
193
+ request_identity=identity,
194
+ )
195
+
196
+ @staticmethod
197
+ def _identity(
198
+ *,
199
+ method: str,
200
+ url: str,
201
+ headers: dict[str, str],
202
+ body: bytes | None,
203
+ ) -> str:
204
+ """Hash the complete normalized request identity for cache/idempotency.
205
+
206
+ Side effects if changes:
207
+ - cache collisions, conditional validation, and job request hashes.
208
+ """
209
+
210
+ payload = json.dumps(
211
+ {
212
+ "method": method,
213
+ "url": url,
214
+ "headers": headers,
215
+ "bodySha256": hashlib.sha256(body or b"").hexdigest(),
216
+ },
217
+ sort_keys=True,
218
+ separators=(",", ":"),
219
+ ).encode()
220
+ return hashlib.sha256(payload).hexdigest()