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.
- keble_scraper_api/__init__.py +3 -0
- keble_scraper_api/api/dependencies.py +100 -0
- keble_scraper_api/api/errors.py +72 -0
- keble_scraper_api/api/router.py +473 -0
- keble_scraper_api/api/schemas.py +26 -0
- keble_scraper_api/application/admin.py +285 -0
- keble_scraper_api/application/artifacts.py +393 -0
- keble_scraper_api/application/auth.py +324 -0
- keble_scraper_api/application/failures.py +427 -0
- keble_scraper_api/application/fetching.py +72 -0
- keble_scraper_api/application/jobs.py +891 -0
- keble_scraper_api/application/proxy_health.py +126 -0
- keble_scraper_api/application/scheduling.py +24 -0
- keble_scraper_api/artifact_namespace.py +76 -0
- keble_scraper_api/container.py +361 -0
- keble_scraper_api/errors.py +25 -0
- keble_scraper_api/infrastructure/artifact_store.py +585 -0
- keble_scraper_api/infrastructure/callbacks.py +285 -0
- keble_scraper_api/infrastructure/celery_factory.py +63 -0
- keble_scraper_api/infrastructure/cloudflare.py +259 -0
- keble_scraper_api/infrastructure/credentials.py +75 -0
- keble_scraper_api/infrastructure/dispatcher.py +57 -0
- keble_scraper_api/infrastructure/http_fetcher.py +351 -0
- keble_scraper_api/infrastructure/mongo.py +2345 -0
- keble_scraper_api/infrastructure/proxy_router.py +460 -0
- keble_scraper_api/infrastructure/request_profiles.py +220 -0
- keble_scraper_api/infrastructure/url_policy.py +341 -0
- keble_scraper_api/main.py +162 -0
- keble_scraper_api/maintenance/__init__.py +1 -0
- keble_scraper_api/maintenance/reset_pre_release_state.py +743 -0
- keble_scraper_api/models.py +473 -0
- keble_scraper_api/protocols.py +947 -0
- keble_scraper_api/settings.py +484 -0
- keble_scraper_api/telemetry.py +109 -0
- keble_scraper_api/workers/celery_app.py +48 -0
- keble_scraper_api/workers/cli.py +106 -0
- keble_scraper_api/workers/runtime.py +78 -0
- keble_scraper_api/workers/tasks.py +97 -0
- keble_scraper_api-0.2.0.dist-info/METADATA +160 -0
- keble_scraper_api-0.2.0.dist-info/RECORD +42 -0
- keble_scraper_api-0.2.0.dist-info/WHEEL +4 -0
- 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
|
+
)
|