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,341 @@
1
+ """DNS-resolving SSRF policy with validated connection targets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import ipaddress
7
+ import socket
8
+ from time import monotonic
9
+ from urllib.parse import urlsplit
10
+
11
+ import httpx
12
+ from keble_helpers.typings import PydanticModelConfig
13
+ from pydantic import AliasChoices, AnyHttpUrl, BaseModel, Field, SecretStr, TypeAdapter
14
+
15
+ from keble_scraper_api.errors import ScraperDomainError, ScraperErrorCode
16
+ from keble_scraper_api.protocols import HostResolverProtocol
17
+
18
+ _HTTP_URL_ADAPTER = TypeAdapter(AnyHttpUrl)
19
+ _EXPLICITLY_DENIED = frozenset(
20
+ {
21
+ ipaddress.ip_address("169.254.169.254"),
22
+ ipaddress.ip_address("100.100.100.200"),
23
+ ipaddress.ip_address("fd00:ec2::254"),
24
+ }
25
+ )
26
+
27
+
28
+ class ValidatedTarget(BaseModel):
29
+ """Public resolved address set pinned for one outbound hop.
30
+
31
+ Side effects if changes:
32
+ - direct/Bright/public connections and redirect revalidation.
33
+ """
34
+
35
+ model_config = PydanticModelConfig.default(frozen=True, extra="forbid")
36
+
37
+ normalized_url: str
38
+ hostname: str
39
+ port: int
40
+ addresses: tuple[str, ...]
41
+
42
+ def pinned_url(self, *, address: str) -> str:
43
+ """Replace the hostname with one validated IP while preserving request bytes.
44
+
45
+ Side effects if changes:
46
+ - TLS SNI/Host pairing and DNS-rebinding resistance.
47
+ """
48
+
49
+ parsed = urlsplit(self.normalized_url)
50
+ host = f"[{address}]" if ":" in address else address
51
+ authority = f"{host}:{self.port}"
52
+ path = parsed.path or "/"
53
+ query = f"?{parsed.query}" if parsed.query else ""
54
+ return f"{parsed.scheme}://{authority}{path}{query}"
55
+
56
+
57
+ class AsyncioHostResolver:
58
+ """Production resolver returning every A/AAAA address from the event loop.
59
+
60
+ Side effects if changes:
61
+ - real DNS resolution and the address set inspected by SSRF policy.
62
+ """
63
+
64
+ async def resolve(self, *, hostname: str, port: int) -> tuple[str, ...]:
65
+ """Resolve one host without filtering so policy can fail closed on mixing.
66
+
67
+ Side effects if changes:
68
+ - DNS traffic and IP-pinning candidates.
69
+ """
70
+
71
+ loop = asyncio.get_running_loop()
72
+ entries = await loop.getaddrinfo(
73
+ hostname,
74
+ port,
75
+ family=socket.AF_UNSPEC,
76
+ type=socket.SOCK_STREAM,
77
+ proto=socket.IPPROTO_TCP,
78
+ )
79
+ return tuple(sorted({str(entry[4][0]) for entry in entries}))
80
+
81
+
82
+ class _DnsOverHttpsAnswer(BaseModel):
83
+ """One typed IP answer from the configured JSON DoH endpoint.
84
+
85
+ Side effects if changes:
86
+ - development resolver response validation only.
87
+ """
88
+
89
+ model_config = PydanticModelConfig.default(frozen=True, extra="ignore")
90
+
91
+ record_type: int = Field(validation_alias=AliasChoices("type", "record_type"))
92
+ ttl: int = Field(default=300, validation_alias=AliasChoices("TTL", "ttl"), ge=0)
93
+ data: str = Field(min_length=1, max_length=512)
94
+
95
+
96
+ class _DnsOverHttpsResponse(BaseModel):
97
+ """Typed subset of one RFC 8484 JSON response.
98
+
99
+ Side effects if changes:
100
+ - development DNS success and answer extraction.
101
+ """
102
+
103
+ model_config = PydanticModelConfig.default(frozen=True, extra="ignore")
104
+
105
+ status: int = Field(validation_alias=AliasChoices("Status", "status"))
106
+ answers: tuple[_DnsOverHttpsAnswer, ...] = Field(
107
+ default=(),
108
+ validation_alias=AliasChoices("Answer", "answers"),
109
+ )
110
+
111
+
112
+ class _DnsOverHttpsResolution(BaseModel):
113
+ """Validated IP answers and their bounded local cache lifetime.
114
+
115
+ Side effects if changes:
116
+ - local resolver cache behavior.
117
+ """
118
+
119
+ model_config = PydanticModelConfig.default(frozen=True, extra="forbid")
120
+
121
+ addresses: tuple[str, ...]
122
+ ttl_seconds: int = Field(ge=30, le=3_600)
123
+
124
+
125
+ class DnsOverHttpsHostResolver:
126
+ """Resolve real public IPs through an explicit development HTTP proxy.
127
+
128
+ Steps:
129
+ 1. cache bounded host answers by provider TTL;
130
+ 2. query A and AAAA families concurrently with fail-fast supervision;
131
+ 3. return only validated IP strings to the SSRF policy.
132
+
133
+ Side effects if changes:
134
+ - local DoH traffic, SSRF address validation inputs, and DNS cache volume.
135
+ """
136
+
137
+ def __init__(
138
+ self,
139
+ *,
140
+ endpoint: AnyHttpUrl,
141
+ proxy_url: SecretStr,
142
+ timeout_seconds: float = 10,
143
+ ) -> None:
144
+ """Bind one reviewed DoH endpoint and secret-redacted local proxy URL.
145
+
146
+ Side effects if changes:
147
+ - development DNS egress and maximum resolver wait.
148
+ """
149
+
150
+ self._endpoint = endpoint
151
+ self._proxy_url = proxy_url
152
+ self._timeout_seconds = timeout_seconds
153
+ self._cache: dict[str, tuple[float, tuple[str, ...]]] = {}
154
+ self._lock = asyncio.Lock()
155
+
156
+ async def resolve(self, *, hostname: str, port: int) -> tuple[str, ...]:
157
+ """Resolve A/AAAA answers once per TTL while retaining fail-closed parsing.
158
+
159
+ Steps:
160
+ 1. return an unexpired host-scoped cache entry when present;
161
+ 2. query A and AAAA through the explicit proxy without ambient settings;
162
+ 3. validate IP answer types and cache only the merged bounded result.
163
+
164
+ Side effects if changes:
165
+ - local DoH request count and the addresses inspected by `PublicUrlPolicy`.
166
+ """
167
+
168
+ del port
169
+ now = monotonic()
170
+ cached = self._cache.get(hostname)
171
+ if cached is not None and cached[0] > now:
172
+ return cached[1]
173
+ async with self._lock:
174
+ now = monotonic()
175
+ cached = self._cache.get(hostname)
176
+ if cached is not None and cached[0] > now:
177
+ return cached[1]
178
+ async with asyncio.TaskGroup() as group:
179
+ ipv4_task = group.create_task(
180
+ self._query(hostname=hostname, record_type=1)
181
+ )
182
+ ipv6_task = group.create_task(
183
+ self._query(hostname=hostname, record_type=28)
184
+ )
185
+ resolutions = (ipv4_task.result(), ipv6_task.result())
186
+ addresses = tuple(
187
+ sorted(
188
+ {
189
+ address
190
+ for resolution in resolutions
191
+ for address in resolution.addresses
192
+ }
193
+ )
194
+ )
195
+ if not addresses:
196
+ return ()
197
+ ttl_seconds = min(resolution.ttl_seconds for resolution in resolutions)
198
+ self._cache[hostname] = (now + ttl_seconds, addresses)
199
+ return addresses
200
+
201
+ async def _query(
202
+ self,
203
+ *,
204
+ hostname: str,
205
+ record_type: int,
206
+ ) -> _DnsOverHttpsResolution:
207
+ """Fetch and parse one JSON DoH record family through the local proxy.
208
+
209
+ Side effects if changes:
210
+ - one explicit development network request on a DNS cache miss.
211
+ """
212
+
213
+ async with httpx.AsyncClient(
214
+ proxy=self._proxy_url.get_secret_value(),
215
+ timeout=self._timeout_seconds,
216
+ trust_env=False,
217
+ ) as client:
218
+ response = await client.get(
219
+ str(self._endpoint),
220
+ params={"name": hostname, "type": str(record_type)},
221
+ headers={"Accept": "application/dns-json"},
222
+ )
223
+ response.raise_for_status()
224
+ return self.parse_response(raw=response.content, record_type=record_type)
225
+
226
+ @staticmethod
227
+ def parse_response(*, raw: bytes, record_type: int) -> _DnsOverHttpsResolution:
228
+ """Convert one JSON DoH payload into validated A or AAAA addresses.
229
+
230
+ Side effects if changes:
231
+ - malformed or mismatched DNS answers admitted to SSRF validation.
232
+ """
233
+
234
+ response = _DnsOverHttpsResponse.model_validate_json(raw)
235
+ if response.status != 0:
236
+ return _DnsOverHttpsResolution(addresses=(), ttl_seconds=30)
237
+ matching = tuple(
238
+ answer for answer in response.answers if answer.record_type == record_type
239
+ )
240
+ addresses = tuple(str(ipaddress.ip_address(answer.data)) for answer in matching)
241
+ ttl_seconds = (
242
+ max(30, min(3_600, min(answer.ttl for answer in matching)))
243
+ if matching
244
+ else 30
245
+ )
246
+ return _DnsOverHttpsResolution(
247
+ addresses=addresses,
248
+ ttl_seconds=ttl_seconds,
249
+ )
250
+
251
+
252
+ class PublicUrlPolicy:
253
+ """Validate public HTTP(S) URLs and resolve every hop before dispatch.
254
+
255
+ Steps:
256
+ 1. validate typed URL syntax and explicit scheme/authority policy;
257
+ 2. resolve every address for the selected host/port;
258
+ 3. reject the whole answer set when any address is non-public.
259
+
260
+ Side effects if changes:
261
+ - generic URL acceptance, Shopify domains, redirects, and SSRF guarantees.
262
+ """
263
+
264
+ def __init__(self, *, resolver: HostResolverProtocol | None = None) -> None:
265
+ """Bind an injectable resolver while defaulting to real event-loop DNS.
266
+
267
+ Side effects if changes:
268
+ - all worker URL validation and deterministic policy tests.
269
+ """
270
+
271
+ self._resolver = resolver or AsyncioHostResolver()
272
+
273
+ async def validate_and_resolve(self, *, url: str) -> ValidatedTarget:
274
+ """Parse, deny private ranges, and return one stable address set.
275
+
276
+ Steps:
277
+ 1. validate through Pydantic without trusting free-form parser recovery;
278
+ 2. allow only HTTP(S), ports 80/443, no credentials/fragments;
279
+ 3. resolve all A/AAAA answers and reject the entire hostname if any
280
+ answer is non-global or explicitly metadata-reserved.
281
+
282
+ Side effects if changes:
283
+ - connection pinning and redirect security.
284
+ """
285
+
286
+ parsed = _HTTP_URL_ADAPTER.validate_python(url)
287
+ if parsed.scheme not in {"http", "https"}:
288
+ raise ScraperDomainError(
289
+ code=ScraperErrorCode.URL_DENIED,
290
+ message="only HTTP(S) resources are permitted",
291
+ )
292
+ if (
293
+ parsed.username is not None
294
+ or parsed.password is not None
295
+ or parsed.fragment
296
+ ):
297
+ raise ScraperDomainError(
298
+ code=ScraperErrorCode.URL_DENIED,
299
+ message="URL credentials and fragments are forbidden",
300
+ )
301
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
302
+ if port not in {80, 443}:
303
+ raise ScraperDomainError(
304
+ code=ScraperErrorCode.URL_DENIED,
305
+ message="only ports 80 and 443 are permitted",
306
+ )
307
+ if parsed.host is None:
308
+ raise ScraperDomainError(
309
+ code=ScraperErrorCode.URL_DENIED,
310
+ message="URL hostname is required",
311
+ )
312
+ hostname = parsed.host.rstrip(".").lower()
313
+ addresses = await self._resolver.resolve(hostname=hostname, port=port)
314
+ if not addresses:
315
+ raise ScraperDomainError(
316
+ code=ScraperErrorCode.DNS_FAILED,
317
+ message="public hostname resolution failed",
318
+ )
319
+ denied = [address for address in addresses if not self._is_public(address)]
320
+ if denied:
321
+ raise ScraperDomainError(
322
+ code=ScraperErrorCode.URL_DENIED,
323
+ message="hostname resolves to a denied network range",
324
+ )
325
+ return ValidatedTarget(
326
+ normalized_url=str(parsed),
327
+ hostname=hostname,
328
+ port=port,
329
+ addresses=addresses,
330
+ )
331
+
332
+ @staticmethod
333
+ def _is_public(address: str) -> bool:
334
+ """Return whether one resolver-produced address is globally routable.
335
+
336
+ Side effects if changes:
337
+ - private, loopback, CGNAT, documentation, multicast, and metadata denial.
338
+ """
339
+
340
+ candidate = ipaddress.ip_address(address)
341
+ return candidate.is_global and candidate not in _EXPLICITLY_DENIED
@@ -0,0 +1,162 @@
1
+ """FastAPI application and process entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncIterator
6
+ from contextlib import asynccontextmanager
7
+ from pathlib import Path
8
+ from types import TracebackType
9
+ from typing import cast
10
+
11
+ import uvicorn
12
+ from cryptography.fernet import InvalidToken
13
+ from fastapi import FastAPI
14
+ from fastapi.staticfiles import StaticFiles
15
+ from starlette.types import ExceptionHandler
16
+
17
+ from keble_scraper_api.api.dependencies import ContainerProvider
18
+ from keble_scraper_api.api.errors import invalid_session_handler, scraper_error_handler
19
+ from keble_scraper_api.api.router import create_router
20
+ from keble_scraper_api.container import ScraperContainer
21
+ from keble_scraper_api.errors import ScraperDomainError
22
+ from keble_scraper_api.settings import ScraperSettings
23
+ from keble_scraper_api.telemetry import TelemetryRuntime, configure_sentry
24
+
25
+
26
+ class ContainerLifecycle:
27
+ """Ensure process resources close through async-context semantics.
28
+
29
+ Steps:
30
+ 1. start one explicitly owned dependency graph;
31
+ 2. expose it only after readiness completes;
32
+ 3. clear request access and close pools on normal lifespan exit.
33
+
34
+ Side effects if changes:
35
+ - FastAPI startup indexes/readiness and graceful connection cleanup.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ container: ScraperContainer,
42
+ provider: ContainerProvider,
43
+ ) -> None:
44
+ """Bind one unstarted container and its static route provider.
45
+
46
+ Side effects if changes:
47
+ - application lifespan ownership.
48
+ """
49
+
50
+ self._container = container
51
+ self._provider = provider
52
+
53
+ async def __aenter__(self) -> ScraperContainer:
54
+ """Start dependencies then expose the bound graph to requests.
55
+
56
+ Steps:
57
+ 1. await startup directly so faults retain their traceback;
58
+ 2. bind request dependencies only after startup succeeds;
59
+ 3. return the singular process container.
60
+
61
+ Side effects if changes:
62
+ - Mongo indexes, readiness calls, and route availability.
63
+ """
64
+
65
+ await self._container.start()
66
+ self._provider.bind(container=self._container)
67
+ return self._container
68
+
69
+ async def __aexit__(
70
+ self,
71
+ exception_type: type[BaseException] | None,
72
+ exception: BaseException | None,
73
+ traceback: TracebackType | None,
74
+ ) -> None:
75
+ """Clear route access and close process-owned pools on every exit.
76
+
77
+ Steps:
78
+ 1. make request dependency resolution fail closed;
79
+ 2. close every process-owned pool in container order;
80
+ 3. retain the original lifespan exception for framework supervision.
81
+
82
+ Side effects if changes:
83
+ - shutdown request behavior and connection leakage.
84
+ """
85
+
86
+ self._provider.clear()
87
+ await self._container.close()
88
+
89
+
90
+ def create_app(*, settings: ScraperSettings | None = None) -> FastAPI:
91
+ """Build the API without connecting until lifespan startup.
92
+
93
+ Steps:
94
+ 1. resolve typed settings and configure process telemetry;
95
+ 2. compose one lazy lifespan-owned container and API router;
96
+ 3. register finite error mappings and optional static frontend serving.
97
+
98
+ Side effects if changes:
99
+ - OpenAPI, Sentry configuration, routes, and optional frontend serving.
100
+ """
101
+
102
+ resolved = settings or ScraperSettings()
103
+ configure_sentry(settings=resolved, runtime=TelemetryRuntime.API)
104
+ provider = ContainerProvider()
105
+
106
+ @asynccontextmanager
107
+ async def lifespan(application: FastAPI) -> AsyncIterator[None]:
108
+ """Start and close one explicit container around request service.
109
+
110
+ Steps:
111
+ 1. build a process-local graph without hidden globals;
112
+ 2. enter its startup/readiness lifecycle;
113
+ 3. yield request service until framework shutdown.
114
+
115
+ Side effects if changes:
116
+ - all API process infrastructure resources.
117
+ """
118
+
119
+ container = ScraperContainer.build(settings=resolved)
120
+ async with ContainerLifecycle(container=container, provider=provider):
121
+ yield
122
+
123
+ app = FastAPI(
124
+ title="Keble Scraper API",
125
+ version="0.2.0",
126
+ lifespan=lifespan,
127
+ )
128
+ app.add_exception_handler(
129
+ ScraperDomainError,
130
+ cast(ExceptionHandler, scraper_error_handler),
131
+ )
132
+ app.add_exception_handler(
133
+ InvalidToken,
134
+ cast(ExceptionHandler, invalid_session_handler),
135
+ )
136
+ app.include_router(create_router(provider=provider))
137
+ frontend_dist = Path(resolved.frontend_dist)
138
+ if frontend_dist.is_dir():
139
+ app.mount(
140
+ "/",
141
+ StaticFiles(directory=frontend_dist, html=True),
142
+ name="scraper-frontend",
143
+ )
144
+ return app
145
+
146
+
147
+ def run() -> None:
148
+ """Run the configured FastAPI process under Uvicorn.
149
+
150
+ Side effects if changes:
151
+ - binds the scraper API network listener.
152
+ """
153
+
154
+ settings = ScraperSettings()
155
+ uvicorn.run(
156
+ create_app(settings=settings),
157
+ host=settings.host,
158
+ port=settings.port,
159
+ )
160
+
161
+
162
+ app = create_app()
@@ -0,0 +1 @@
1
+ """Guarded operator maintenance services and typed command contracts."""