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,57 @@
1
+ """Celery-backed asynchronous job dispatcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from datetime import datetime
7
+ from uuid import UUID
8
+
9
+ from celery import Celery
10
+
11
+ from keble_scraper_contract import ScrapeTransportPolicy
12
+
13
+ from keble_scraper_api.settings import ScraperSettings
14
+
15
+
16
+ class CeleryJobDispatcher:
17
+ """Publish only opaque job identities to the owning worker queue.
18
+
19
+ Side effects if changes:
20
+ - RabbitMQ messages, queue isolation, and render/fetch worker load.
21
+ """
22
+
23
+ def __init__(self, *, celery: Celery, settings: ScraperSettings) -> None:
24
+ """Bind one broker client and settings-owned queue names.
25
+
26
+ Side effects if changes:
27
+ - all API/maintenance task publication.
28
+ """
29
+
30
+ self._celery = celery
31
+ self._settings = settings
32
+
33
+ async def dispatch(
34
+ self,
35
+ *,
36
+ job_id: UUID,
37
+ transport_policy: ScrapeTransportPolicy,
38
+ not_before: datetime | None = None,
39
+ ) -> None:
40
+ """Publish a fetch/render task with optional broker ETA.
41
+
42
+ Side effects if changes:
43
+ - one RabbitMQ task message and worker scheduling.
44
+ """
45
+
46
+ queue = (
47
+ self._settings.render_queue
48
+ if transport_policy is ScrapeTransportPolicy.BROWSER_EXCEPTION
49
+ else self._settings.fetch_queue
50
+ )
51
+ await asyncio.to_thread(
52
+ self._celery.send_task,
53
+ "keble_scraper.execute_job",
54
+ args=[str(job_id)],
55
+ queue=queue,
56
+ eta=not_before,
57
+ )
@@ -0,0 +1,351 @@
1
+ """TLS-validating, IP-pinned, redirect-revalidating HTTP fetcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from datetime import UTC, datetime
7
+ from time import monotonic
8
+ from urllib.parse import urljoin
9
+
10
+ import aiohttp
11
+ from keble_data_infra_contract import parse_retry_after
12
+ from keble_helpers.typings import PydanticModelConfig
13
+ from pydantic import BaseModel
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.models import RouteReservation
23
+ from keble_scraper_api.settings import ResponsePolicy, ScraperSettings
24
+
25
+ from .url_policy import PublicUrlPolicy, ValidatedTarget
26
+
27
+
28
+ class _HopResult(BaseModel):
29
+ """One IP-pinned HTTP hop before redirect orchestration.
30
+
31
+ Side effects if changes:
32
+ - redirect validation and final typed fetch outcomes.
33
+ """
34
+
35
+ model_config = PydanticModelConfig.default(frozen=True, extra="forbid")
36
+
37
+ result: FetchResult
38
+ redirect_location: str | None = None
39
+
40
+
41
+ class SecureHttpFetcher:
42
+ """Fetch static resources through direct/Bright/public routes safely.
43
+
44
+ Steps:
45
+ 1. validate and pin every initial/redirect URL hop;
46
+ 2. reuse one pooled client while retaining per-request SNI/proxy policy;
47
+ 3. stream bytes within response limits into typed outcomes.
48
+
49
+ Side effects if changes:
50
+ - TLS validation, SSRF pinning, limits, redirects, and retry outcomes.
51
+ """
52
+
53
+ def __init__(
54
+ self, *, settings: ScraperSettings, url_policy: PublicUrlPolicy
55
+ ) -> None:
56
+ """Bind immutable server policy and resolver.
57
+
58
+ Steps:
59
+ 1. retain typed fetch and URL policies;
60
+ 2. initialize a lazy pooled-session slot;
61
+ 3. initialize a lock that serializes first session construction.
62
+
63
+ Side effects if changes:
64
+ - every worker fetch shares this response and URL policy.
65
+ """
66
+
67
+ self._settings = settings
68
+ self._url_policy = url_policy
69
+ self._session: aiohttp.ClientSession | None = None
70
+ self._session_lock = asyncio.Lock()
71
+
72
+ async def close(self) -> None:
73
+ """Close the process-owned pooled HTTP session exactly once.
74
+
75
+ Steps:
76
+ 1. return when no outbound request created the lazy session;
77
+ 2. close all pooled direct/proxy connections;
78
+ 3. clear the reference so repeated shutdown remains idempotent.
79
+
80
+ Side effects if changes:
81
+ - API/worker shutdown and socket/connector leak prevention.
82
+ """
83
+
84
+ if self._session is None:
85
+ return
86
+ await self._session.close()
87
+ self._session = None
88
+
89
+ async def fetch(
90
+ self,
91
+ *,
92
+ request: PreparedFetch,
93
+ reservation: RouteReservation,
94
+ response_policy: ResponsePolicy,
95
+ conditional_headers: dict[str, str] | None = None,
96
+ ) -> FetchResult:
97
+ """Follow at most five independently validated and pinned hops.
98
+
99
+ Steps:
100
+ 1. resolve and validate every URL before opening a connection;
101
+ 2. connect to the chosen IP while retaining original Host/TLS SNI;
102
+ 3. re-run policy for each redirect and stream within server byte limits.
103
+
104
+ Side effects if changes:
105
+ - cache validators, immutable attempt outcomes, and artifacts.
106
+ """
107
+
108
+ current_url = request.url
109
+ for redirect_count in range(self._settings.max_redirects + 1):
110
+ target = await self._url_policy.validate_and_resolve(url=current_url)
111
+ hop = await self._fetch_hop(
112
+ request=request,
113
+ target=target,
114
+ reservation=reservation,
115
+ response_policy=response_policy,
116
+ conditional_headers=conditional_headers or {},
117
+ )
118
+ if hop.redirect_location is None:
119
+ return hop.result
120
+ if redirect_count == self._settings.max_redirects:
121
+ return FetchResult(
122
+ outcome=ScrapeAttemptOutcome.DENIED,
123
+ route=reservation.route,
124
+ trust=self._trust(reservation.route),
125
+ final_url=current_url,
126
+ elapsed_ms=hop.result.elapsed_ms,
127
+ error_code="REDIRECT_LIMIT",
128
+ observed_at=datetime.now(UTC),
129
+ )
130
+ current_url = urljoin(target.normalized_url, hop.redirect_location)
131
+ raise AssertionError("bounded redirect loop must return")
132
+
133
+ async def _fetch_hop(
134
+ self,
135
+ *,
136
+ request: PreparedFetch,
137
+ target: ValidatedTarget,
138
+ reservation: RouteReservation,
139
+ response_policy: ResponsePolicy,
140
+ conditional_headers: dict[str, str],
141
+ ) -> _HopResult:
142
+ """Execute one hop while allowing implementation/transport faults to supervise.
143
+
144
+ Steps:
145
+ 1. construct the pinned request and bounded timeout policy;
146
+ 2. call the pooled client without converting exceptions into business data;
147
+ 3. attach measured elapsed time to a successfully typed transport result.
148
+
149
+ Side effects if changes:
150
+ - IP pinning, TLS verification, response limits, and route accounting.
151
+ """
152
+
153
+ started = monotonic()
154
+ address = target.addresses[0]
155
+ headers = {
156
+ "User-Agent": self._settings.identified_user_agent,
157
+ "Host": target.hostname,
158
+ **request.headers,
159
+ **conditional_headers,
160
+ }
161
+ timeout = aiohttp.ClientTimeout(
162
+ total=self._settings.connect_timeout_seconds
163
+ + self._settings.read_timeout_seconds,
164
+ connect=self._settings.connect_timeout_seconds,
165
+ sock_read=self._settings.read_timeout_seconds,
166
+ )
167
+ raw = await self._send(
168
+ method=request.method,
169
+ url=target.pinned_url(address=address),
170
+ original_url=target.normalized_url,
171
+ server_hostname=target.hostname,
172
+ headers=headers,
173
+ body=request.body,
174
+ proxy_url=(
175
+ reservation.proxy_url.get_secret_value()
176
+ if reservation.proxy_url is not None
177
+ else self._development_proxy_url(reservation=reservation)
178
+ ),
179
+ timeout=timeout,
180
+ response_policy=response_policy,
181
+ route=reservation.route,
182
+ )
183
+ elapsed_ms = max(0, int((monotonic() - started) * 1000))
184
+ result, redirect = raw
185
+ return _HopResult(
186
+ result=result.model_copy(update={"elapsed_ms": elapsed_ms}),
187
+ redirect_location=redirect,
188
+ )
189
+
190
+ def _development_proxy_url(self, *, reservation: RouteReservation) -> str | None:
191
+ """Return explicit local egress only for the verified DIRECT route.
192
+
193
+ Side effects if changes:
194
+ - development network path and production direct-route isolation.
195
+ """
196
+
197
+ if (
198
+ reservation.route is ScrapeTransportRoute.DIRECT
199
+ and self._settings.development_http_proxy_url is not None
200
+ ):
201
+ return self._settings.development_http_proxy_url.get_secret_value()
202
+ return None
203
+
204
+ async def _send(
205
+ self,
206
+ *,
207
+ method: str,
208
+ url: str,
209
+ original_url: str,
210
+ server_hostname: str,
211
+ headers: dict[str, str],
212
+ body: bytes | None,
213
+ proxy_url: str | None,
214
+ timeout: aiohttp.ClientTimeout,
215
+ response_policy: ResponsePolicy,
216
+ route: ScrapeTransportRoute,
217
+ ) -> tuple[FetchResult, str | None]:
218
+ """Stream one pooled aiohttp response within its byte ceiling.
219
+
220
+ Steps:
221
+ 1. reuse the process-owned client session for connection pooling;
222
+ 2. preserve the declared content type and parse shared Retry-After evidence;
223
+ 3. stream bounded bytes into one finite HTTP/content/size outcome.
224
+
225
+ Side effects if changes:
226
+ - actual network egress and response artifact bytes.
227
+ """
228
+
229
+ session = await self._client_session()
230
+ async with session.request(
231
+ method,
232
+ url,
233
+ headers=headers,
234
+ data=body,
235
+ proxy=proxy_url,
236
+ allow_redirects=False,
237
+ server_hostname=server_hostname,
238
+ timeout=timeout,
239
+ ) as response:
240
+ status = response.status
241
+ location = (
242
+ response.headers.get("Location")
243
+ if status in {301, 302, 303, 307, 308}
244
+ else None
245
+ )
246
+ content_type = response.headers.get(
247
+ "Content-Type",
248
+ "application/octet-stream",
249
+ ).strip()
250
+ media_type = content_type.split(";", 1)[0].strip().lower()
251
+ observed_at = datetime.now(UTC)
252
+ retry_at = parse_retry_after(
253
+ value=response.headers.get("Retry-After"),
254
+ observed_at=observed_at,
255
+ )
256
+ base = {
257
+ "route": route,
258
+ "trust": self._trust(route),
259
+ "final_url": original_url,
260
+ "status_code": status,
261
+ "content_type": content_type,
262
+ "etag": response.headers.get("ETag"),
263
+ "last_modified": response.headers.get("Last-Modified"),
264
+ "elapsed_ms": 0,
265
+ "retry_at": retry_at,
266
+ "observed_at": observed_at,
267
+ }
268
+ if location is not None:
269
+ return FetchResult(
270
+ outcome=ScrapeAttemptOutcome.SUCCEEDED, **base
271
+ ), location
272
+ if status >= 400:
273
+ return FetchResult(
274
+ outcome=ScrapeAttemptOutcome.HTTP_ERROR,
275
+ error_code="HTTP_ERROR",
276
+ **base,
277
+ ), None
278
+ if status == 304 or method == "HEAD":
279
+ return FetchResult(outcome=ScrapeAttemptOutcome.SUCCEEDED, **base), None
280
+ if not any(
281
+ media_type.startswith(prefix)
282
+ for prefix in response_policy.content_type_prefixes
283
+ ):
284
+ return FetchResult(
285
+ outcome=ScrapeAttemptOutcome.CONTENT_TYPE_REJECTED,
286
+ error_code="CONTENT_TYPE_REJECTED",
287
+ **base,
288
+ ), None
289
+ declared_length = response.headers.get("Content-Length", "")
290
+ if (
291
+ declared_length.isdigit()
292
+ and int(declared_length) > response_policy.max_bytes
293
+ ):
294
+ return FetchResult(
295
+ outcome=ScrapeAttemptOutcome.SIZE_LIMIT,
296
+ error_code="RESPONSE_TOO_LARGE",
297
+ **base,
298
+ ), None
299
+ chunks: list[bytes] = []
300
+ received = 0
301
+ async for chunk in response.content.iter_chunked(65_536):
302
+ received += len(chunk)
303
+ if received > response_policy.max_bytes:
304
+ return FetchResult(
305
+ outcome=ScrapeAttemptOutcome.SIZE_LIMIT,
306
+ error_code="RESPONSE_TOO_LARGE",
307
+ **base,
308
+ ), None
309
+ chunks.append(chunk)
310
+ return FetchResult(
311
+ outcome=ScrapeAttemptOutcome.SUCCEEDED,
312
+ body=b"".join(chunks),
313
+ **base,
314
+ ), None
315
+
316
+ async def _client_session(self) -> aiohttp.ClientSession:
317
+ """Return one lazy process-owned pooled aiohttp session.
318
+
319
+ Steps:
320
+ 1. return the open session on the hot path;
321
+ 2. serialize first construction for concurrent callers;
322
+ 3. configure no ambient proxy trust and no automatic status exceptions.
323
+
324
+ Side effects if changes:
325
+ - connection reuse, DNS/socket volume, and shutdown ownership.
326
+ """
327
+
328
+ if self._session is not None and not self._session.closed:
329
+ return self._session
330
+ async with self._session_lock:
331
+ if self._session is None or self._session.closed:
332
+ self._session = aiohttp.ClientSession(
333
+ trust_env=False,
334
+ auto_decompress=True,
335
+ raise_for_status=False,
336
+ )
337
+ return self._session
338
+
339
+ @staticmethod
340
+ def _trust(route: ScrapeTransportRoute) -> ScrapeArtifactTrust:
341
+ """Map public-proxy experiments to non-indexable trust.
342
+
343
+ Side effects if changes:
344
+ - Shopify production artifact acceptance.
345
+ """
346
+
347
+ return (
348
+ ScrapeArtifactTrust.UNTRUSTED
349
+ if route is ScrapeTransportRoute.PUBLIC_POOL_EXPERIMENT
350
+ else ScrapeArtifactTrust.VERIFIED
351
+ )