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,484 @@
|
|
|
1
|
+
"""Environment-only typed runtime configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from keble_helpers.typings import PydanticModelConfig
|
|
10
|
+
from pydantic import (
|
|
11
|
+
AnyHttpUrl,
|
|
12
|
+
BaseModel,
|
|
13
|
+
Field,
|
|
14
|
+
SecretStr,
|
|
15
|
+
TypeAdapter,
|
|
16
|
+
field_validator,
|
|
17
|
+
model_validator,
|
|
18
|
+
)
|
|
19
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
20
|
+
|
|
21
|
+
from keble_scraper_contract import OperatorRole
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RuntimeEnvironment(StrEnum):
|
|
25
|
+
"""Finite deployment environments with explicit auth behavior.
|
|
26
|
+
|
|
27
|
+
Side effects if changes:
|
|
28
|
+
- production startup validation and development auth bypass.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
DEVELOPMENT = "development"
|
|
32
|
+
TEST = "test"
|
|
33
|
+
STAGING = "staging"
|
|
34
|
+
PRODUCTION = "production"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ResponsePolicy(BaseModel):
|
|
38
|
+
"""Server-owned content-type and streaming byte limits.
|
|
39
|
+
|
|
40
|
+
Side effects if changes:
|
|
41
|
+
- secure fetcher acceptance and artifact storage bounds.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
model_config = PydanticModelConfig.default(frozen=True, extra="forbid")
|
|
45
|
+
|
|
46
|
+
max_bytes: int = Field(ge=1, le=1_000_000_000)
|
|
47
|
+
content_type_prefixes: tuple[str, ...]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ScraperSettings(BaseSettings):
|
|
51
|
+
"""Single validated settings source for API, workers, beat, and routing.
|
|
52
|
+
|
|
53
|
+
Steps:
|
|
54
|
+
1. parse environment/Infisical-compatible values into strong types;
|
|
55
|
+
2. normalize optional blanks and reject partial credential tuples;
|
|
56
|
+
3. expose immutable runtime, capacity, cost, and ownership policy.
|
|
57
|
+
|
|
58
|
+
Side effects if changes:
|
|
59
|
+
- Mongo/Redis/Rabbit/OSS ownership and queue names;
|
|
60
|
+
- vendor credentials, spend gates, auth, callbacks, and Sentry startup.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
model_config = SettingsConfigDict(
|
|
64
|
+
env_prefix="SCRAPER_",
|
|
65
|
+
env_file=".env",
|
|
66
|
+
extra="ignore",
|
|
67
|
+
frozen=True,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
environment: RuntimeEnvironment = RuntimeEnvironment.DEVELOPMENT
|
|
71
|
+
host: str = "0.0.0.0"
|
|
72
|
+
port: int = Field(default=8080, ge=1, le=65_535)
|
|
73
|
+
frontend_dist: str = "/app/frontend/dist"
|
|
74
|
+
public_base_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8080")
|
|
75
|
+
mongo_uri: SecretStr = SecretStr("mongodb://localhost:27017/?replicaSet=rs0")
|
|
76
|
+
mongo_database: str = "keble_scraper"
|
|
77
|
+
redis_uri: SecretStr = SecretStr("redis://localhost:9736/0")
|
|
78
|
+
redis_namespace: str = "keble-scraper"
|
|
79
|
+
rabbitmq_uri: SecretStr = SecretStr(
|
|
80
|
+
"amqp://guest:guest@localhost:5672/keble-scraper"
|
|
81
|
+
)
|
|
82
|
+
fetch_queue: str = "keble-scraper.fetch"
|
|
83
|
+
render_queue: str = "keble-scraper.render"
|
|
84
|
+
proxy_health_queue: str = "keble-scraper.proxy-health"
|
|
85
|
+
maintenance_queue: str = "keble-scraper.maintenance"
|
|
86
|
+
fetch_worker_concurrency: int = Field(default=8, ge=1, le=128)
|
|
87
|
+
render_worker_concurrency: int = Field(default=1, ge=1, le=16)
|
|
88
|
+
proxy_health_worker_concurrency: int = Field(default=4, ge=1, le=64)
|
|
89
|
+
maintenance_worker_concurrency: int = Field(default=2, ge=1, le=32)
|
|
90
|
+
worker_lease_seconds: int = Field(default=300, ge=30, le=3_600)
|
|
91
|
+
max_attempts: int = Field(default=4, ge=1, le=10)
|
|
92
|
+
job_deadline_seconds: int = Field(default=259_200, ge=300, le=604_800)
|
|
93
|
+
retry_base_seconds: int = Field(default=30, ge=1, le=3_600)
|
|
94
|
+
upstream_rate_limit_default_seconds: int = Field(
|
|
95
|
+
default=60,
|
|
96
|
+
ge=1,
|
|
97
|
+
le=86_400,
|
|
98
|
+
)
|
|
99
|
+
maintenance_dispatch_concurrency: int = Field(default=32, ge=1, le=128)
|
|
100
|
+
terminal_history_retention_days: int = Field(default=90, ge=30, le=3_650)
|
|
101
|
+
delivered_outbox_retention_days: int = Field(default=30, ge=7, le=3_650)
|
|
102
|
+
audit_history_retention_days: int = Field(default=400, ge=90, le=3_650)
|
|
103
|
+
history_retention_batch_size: int = Field(default=1_000, ge=1, le=10_000)
|
|
104
|
+
history_retention_lease_seconds: int = Field(default=300, ge=30, le=3_600)
|
|
105
|
+
connect_timeout_seconds: float = Field(default=10, gt=0, le=120)
|
|
106
|
+
read_timeout_seconds: float = Field(default=30, gt=0, le=600)
|
|
107
|
+
max_redirects: int = Field(default=5, ge=0, le=10)
|
|
108
|
+
identified_user_agent: str = "KebleDataBot/0.1 (+https://keble.ai/data-crawler)"
|
|
109
|
+
document_max_bytes: int = Field(default=10_000_000, ge=1)
|
|
110
|
+
json_max_bytes: int = Field(default=20_000_000, ge=1)
|
|
111
|
+
sitemap_max_bytes: int = Field(default=50_000_000, ge=1)
|
|
112
|
+
media_max_bytes: int = Field(default=100_000_000, ge=1)
|
|
113
|
+
service_key_current: SecretStr = SecretStr("development-service-key-change-me")
|
|
114
|
+
service_key_next: SecretStr | None = None
|
|
115
|
+
service_caller_identity: str = "keble-shopify"
|
|
116
|
+
callback_hmac_current: SecretStr = SecretStr("development-hmac-key-change-me")
|
|
117
|
+
callback_hmac_next: SecretStr | None = None
|
|
118
|
+
callback_replay_seconds: int = Field(default=300, ge=30, le=3_600)
|
|
119
|
+
shopify_callback_url: AnyHttpUrl | None = None
|
|
120
|
+
session_fernet_key: SecretStr | None = None
|
|
121
|
+
session_cookie_name: str = "keble_scraper_session"
|
|
122
|
+
session_hours: int = Field(default=8, ge=1, le=24)
|
|
123
|
+
google_client_id: str | None = None
|
|
124
|
+
truth_base_url: AnyHttpUrl | None = None
|
|
125
|
+
truth_auth_path: str = "/auth/google/workspace/exchange"
|
|
126
|
+
truth_timeout_seconds: float = Field(default=10, gt=0, le=60)
|
|
127
|
+
dev_auth_enabled: bool = False
|
|
128
|
+
dev_auth_email: str = "local-scraper@keble.dev"
|
|
129
|
+
dev_auth_role: OperatorRole = OperatorRole.SUPADMIN
|
|
130
|
+
bright_proxy_host: str | None = None
|
|
131
|
+
bright_proxy_port: int = Field(default=33335, ge=1, le=65_535)
|
|
132
|
+
bright_customer: str | None = None
|
|
133
|
+
bright_zone: str | None = None
|
|
134
|
+
bright_password: SecretStr | None = None
|
|
135
|
+
bright_cost_per_gb_usd: Decimal = Field(default=Decimal("8"), gt=0)
|
|
136
|
+
bright_daily_budget_usd: Decimal = Field(default=Decimal("25"), ge=0)
|
|
137
|
+
bright_requests_per_second: Decimal = Field(default=Decimal("5"), gt=0)
|
|
138
|
+
direct_requests_per_second: Decimal = Field(default=Decimal("2"), gt=0)
|
|
139
|
+
development_http_proxy_url: SecretStr | None = None
|
|
140
|
+
development_dns_over_https_url: AnyHttpUrl | None = None
|
|
141
|
+
cloudflare_account_id: str | None = None
|
|
142
|
+
cloudflare_api_token: SecretStr | None = None
|
|
143
|
+
cloudflare_browser_domain_allowlist: tuple[str, ...] = ()
|
|
144
|
+
cloudflare_browser_cost_per_hour_usd: Decimal = Field(
|
|
145
|
+
default=Decimal("0.09"),
|
|
146
|
+
gt=0,
|
|
147
|
+
)
|
|
148
|
+
cloudflare_daily_budget_usd: Decimal = Field(default=Decimal("5"), ge=0)
|
|
149
|
+
cloudflare_requests_per_second: Decimal = Field(default=Decimal("10"), gt=0)
|
|
150
|
+
public_proxy_experiment_enabled: bool = False
|
|
151
|
+
public_proxy_production_commit_allowed: bool = False
|
|
152
|
+
public_proxy_feed_allowlist: tuple[str, ...] = ()
|
|
153
|
+
proxy_secret_fernet_key: SecretStr | None = None
|
|
154
|
+
oss_endpoint: AnyHttpUrl | None = None
|
|
155
|
+
oss_bucket: str | None = Field(default=None, min_length=3, max_length=255)
|
|
156
|
+
oss_access_key_id: str | None = Field(default=None, min_length=1, max_length=256)
|
|
157
|
+
oss_access_key_secret: SecretStr | None = Field(default=None, min_length=1)
|
|
158
|
+
oss_prefix: str = "keble-scraper"
|
|
159
|
+
artifact_namespace_owner: str | None = Field(
|
|
160
|
+
default=None,
|
|
161
|
+
min_length=3,
|
|
162
|
+
max_length=256,
|
|
163
|
+
pattern=r"^[A-Za-z0-9._:/-]+$",
|
|
164
|
+
)
|
|
165
|
+
local_artifact_directory: str | None = None
|
|
166
|
+
local_artifact_base_url: AnyHttpUrl | None = None
|
|
167
|
+
artifact_download_seconds: int = Field(default=300, ge=30, le=3_600)
|
|
168
|
+
sentry_dsn: SecretStr | None = None
|
|
169
|
+
sentry_traces_sample_rate: float = Field(default=0.1, ge=0, le=1)
|
|
170
|
+
sentry_release: str = "keble-scraper-api@0.2.0"
|
|
171
|
+
|
|
172
|
+
@field_validator(
|
|
173
|
+
"local_artifact_directory",
|
|
174
|
+
"local_artifact_base_url",
|
|
175
|
+
mode="before",
|
|
176
|
+
)
|
|
177
|
+
@classmethod
|
|
178
|
+
def blank_local_artifact_value_is_absent(cls, value: object) -> object | None:
|
|
179
|
+
"""Normalize explicit blank local-store overrides to absence.
|
|
180
|
+
|
|
181
|
+
1. Allow Compose to disable legacy env-file local storage explicitly.
|
|
182
|
+
2. Preserve every non-blank typed value for normal Pydantic validation.
|
|
183
|
+
|
|
184
|
+
Side effects if changes:
|
|
185
|
+
- private OSS may replace local development artifacts without a dual-store conflict;
|
|
186
|
+
- non-blank local directory/base-URL pairing remains strictly validated.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
if isinstance(value, str) and not value.strip():
|
|
190
|
+
return None
|
|
191
|
+
return value
|
|
192
|
+
|
|
193
|
+
@field_validator(
|
|
194
|
+
"oss_endpoint",
|
|
195
|
+
"oss_bucket",
|
|
196
|
+
"oss_access_key_id",
|
|
197
|
+
"oss_access_key_secret",
|
|
198
|
+
mode="before",
|
|
199
|
+
)
|
|
200
|
+
@classmethod
|
|
201
|
+
def blank_oss_value_is_absent(cls, value: object) -> object | None:
|
|
202
|
+
"""Normalize blank OSS environment overrides to true absence.
|
|
203
|
+
|
|
204
|
+
Steps:
|
|
205
|
+
1. treat blank strings as an operator-disabled OSS value;
|
|
206
|
+
2. treat an empty ``SecretStr`` as equally absent;
|
|
207
|
+
3. preserve every non-blank value for normal URL/length validation.
|
|
208
|
+
|
|
209
|
+
Side effects if changes:
|
|
210
|
+
- local storage may coexist only with a genuinely absent OSS tuple;
|
|
211
|
+
- production and worker startup reject partial OSS credentials before paid work.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
if isinstance(value, str) and not value.strip():
|
|
215
|
+
return None
|
|
216
|
+
if isinstance(value, SecretStr) and not value.get_secret_value().strip():
|
|
217
|
+
return None
|
|
218
|
+
return value
|
|
219
|
+
|
|
220
|
+
@model_validator(mode="after")
|
|
221
|
+
def validate_environment_guards(self) -> "ScraperSettings":
|
|
222
|
+
"""Reject development access and unsafe proxy commits in production.
|
|
223
|
+
|
|
224
|
+
Steps:
|
|
225
|
+
1. reject development-only auth/proxy behavior outside its allowed scope;
|
|
226
|
+
2. require a stable artifact namespace owner for staging/production;
|
|
227
|
+
3. validate complete secure production credentials, URLs, and key rotation.
|
|
228
|
+
|
|
229
|
+
Side effects if changes:
|
|
230
|
+
- production startup, public-proxy quarantine, and console access.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
if (
|
|
234
|
+
self.environment is not RuntimeEnvironment.DEVELOPMENT
|
|
235
|
+
and self.dev_auth_enabled
|
|
236
|
+
):
|
|
237
|
+
raise ValueError("SCRAPER_DEV_AUTH_ENABLED is allowed only in development")
|
|
238
|
+
if (
|
|
239
|
+
self.environment is RuntimeEnvironment.PRODUCTION
|
|
240
|
+
and self.public_proxy_production_commit_allowed
|
|
241
|
+
):
|
|
242
|
+
raise ValueError(
|
|
243
|
+
"public proxy artifacts are forbidden for v0.1 production commits"
|
|
244
|
+
)
|
|
245
|
+
if (
|
|
246
|
+
self.environment
|
|
247
|
+
in {RuntimeEnvironment.STAGING, RuntimeEnvironment.PRODUCTION}
|
|
248
|
+
and self.artifact_namespace_owner is None
|
|
249
|
+
):
|
|
250
|
+
raise ValueError(
|
|
251
|
+
"SCRAPER_ARTIFACT_NAMESPACE_OWNER is required outside local/test runtimes"
|
|
252
|
+
)
|
|
253
|
+
if self.environment is RuntimeEnvironment.PRODUCTION:
|
|
254
|
+
required = {
|
|
255
|
+
"SCRAPER_GOOGLE_CLIENT_ID": self.google_client_id,
|
|
256
|
+
"SCRAPER_TRUTH_BASE_URL": self.truth_base_url,
|
|
257
|
+
"SCRAPER_SHOPIFY_CALLBACK_URL": self.shopify_callback_url,
|
|
258
|
+
"SCRAPER_SESSION_FERNET_KEY": self.session_fernet_key,
|
|
259
|
+
"SCRAPER_PROXY_SECRET_FERNET_KEY": self.proxy_secret_fernet_key,
|
|
260
|
+
"SCRAPER_BRIGHT_PROXY_HOST": self.bright_proxy_host,
|
|
261
|
+
"SCRAPER_BRIGHT_CUSTOMER": self.bright_customer,
|
|
262
|
+
"SCRAPER_BRIGHT_ZONE": self.bright_zone,
|
|
263
|
+
"SCRAPER_BRIGHT_PASSWORD": self.bright_password,
|
|
264
|
+
"SCRAPER_OSS_ENDPOINT": self.oss_endpoint,
|
|
265
|
+
"SCRAPER_OSS_BUCKET": self.oss_bucket,
|
|
266
|
+
"SCRAPER_OSS_ACCESS_KEY_ID": self.oss_access_key_id,
|
|
267
|
+
"SCRAPER_OSS_ACCESS_KEY_SECRET": self.oss_access_key_secret,
|
|
268
|
+
"SCRAPER_SENTRY_DSN": self.sentry_dsn,
|
|
269
|
+
}
|
|
270
|
+
missing = [name for name, value in required.items() if value is None]
|
|
271
|
+
if missing:
|
|
272
|
+
raise ValueError(
|
|
273
|
+
"production scraper settings are missing: " + ", ".join(missing)
|
|
274
|
+
)
|
|
275
|
+
secure_urls = {
|
|
276
|
+
"SCRAPER_PUBLIC_BASE_URL": self.public_base_url,
|
|
277
|
+
"SCRAPER_TRUTH_BASE_URL": self.truth_base_url,
|
|
278
|
+
"SCRAPER_SHOPIFY_CALLBACK_URL": self.shopify_callback_url,
|
|
279
|
+
"SCRAPER_OSS_ENDPOINT": self.oss_endpoint,
|
|
280
|
+
}
|
|
281
|
+
insecure = [
|
|
282
|
+
name
|
|
283
|
+
for name, value in secure_urls.items()
|
|
284
|
+
if value is not None and value.scheme != "https"
|
|
285
|
+
]
|
|
286
|
+
if insecure:
|
|
287
|
+
raise ValueError(
|
|
288
|
+
"production scraper URLs must use HTTPS: " + ", ".join(insecure)
|
|
289
|
+
)
|
|
290
|
+
secrets = {
|
|
291
|
+
"SCRAPER_SERVICE_KEY_CURRENT": self.service_key_current,
|
|
292
|
+
"SCRAPER_SERVICE_KEY_NEXT": self.service_key_next,
|
|
293
|
+
"SCRAPER_CALLBACK_HMAC_CURRENT": self.callback_hmac_current,
|
|
294
|
+
"SCRAPER_CALLBACK_HMAC_NEXT": self.callback_hmac_next,
|
|
295
|
+
"SCRAPER_BRIGHT_PASSWORD": self.bright_password,
|
|
296
|
+
"SCRAPER_OSS_ACCESS_KEY_SECRET": self.oss_access_key_secret,
|
|
297
|
+
}
|
|
298
|
+
weak = [
|
|
299
|
+
name
|
|
300
|
+
for name, value in secrets.items()
|
|
301
|
+
if value is not None and len(value.get_secret_value()) < 32
|
|
302
|
+
]
|
|
303
|
+
if weak:
|
|
304
|
+
raise ValueError(
|
|
305
|
+
"production scraper secrets must contain at least 32 characters: "
|
|
306
|
+
+ ", ".join(weak)
|
|
307
|
+
)
|
|
308
|
+
fernet_keys = {
|
|
309
|
+
"SCRAPER_SESSION_FERNET_KEY": self.session_fernet_key,
|
|
310
|
+
"SCRAPER_PROXY_SECRET_FERNET_KEY": self.proxy_secret_fernet_key,
|
|
311
|
+
}
|
|
312
|
+
invalid_fernet = [
|
|
313
|
+
name
|
|
314
|
+
for name, value in fernet_keys.items()
|
|
315
|
+
if value is not None and len(value.get_secret_value()) != 44
|
|
316
|
+
]
|
|
317
|
+
if invalid_fernet:
|
|
318
|
+
raise ValueError(
|
|
319
|
+
"production Fernet keys must be URL-safe 32-byte keys: "
|
|
320
|
+
+ ", ".join(invalid_fernet)
|
|
321
|
+
)
|
|
322
|
+
if (
|
|
323
|
+
self.service_key_next is not None
|
|
324
|
+
and self.service_key_next.get_secret_value()
|
|
325
|
+
== self.service_key_current.get_secret_value()
|
|
326
|
+
):
|
|
327
|
+
raise ValueError("service current and next keys must differ")
|
|
328
|
+
if (
|
|
329
|
+
self.callback_hmac_next is not None
|
|
330
|
+
and self.callback_hmac_next.get_secret_value()
|
|
331
|
+
== self.callback_hmac_current.get_secret_value()
|
|
332
|
+
):
|
|
333
|
+
raise ValueError("callback current and next keys must differ")
|
|
334
|
+
cloudflare_pair = (
|
|
335
|
+
self.cloudflare_account_id is not None,
|
|
336
|
+
self.cloudflare_api_token is not None,
|
|
337
|
+
)
|
|
338
|
+
if any(cloudflare_pair) and not all(cloudflare_pair):
|
|
339
|
+
raise ValueError(
|
|
340
|
+
"Cloudflare account and API token must be configured together"
|
|
341
|
+
)
|
|
342
|
+
if all(cloudflare_pair) and not self.cloudflare_browser_domain_allowlist:
|
|
343
|
+
raise ValueError("Cloudflare browser rendering requires a domain allowlist")
|
|
344
|
+
invalid_browser_domains = [
|
|
345
|
+
domain
|
|
346
|
+
for domain in self.cloudflare_browser_domain_allowlist
|
|
347
|
+
if not domain
|
|
348
|
+
or domain != domain.strip().lower().rstrip(".")
|
|
349
|
+
or "." not in domain
|
|
350
|
+
or any(token in domain for token in ("://", "/", "@", "?", "#"))
|
|
351
|
+
]
|
|
352
|
+
if invalid_browser_domains:
|
|
353
|
+
raise ValueError(
|
|
354
|
+
"Cloudflare browser allowlist must contain bare lowercase domains"
|
|
355
|
+
)
|
|
356
|
+
local_artifact_pair = (
|
|
357
|
+
self.local_artifact_directory is not None,
|
|
358
|
+
self.local_artifact_base_url is not None,
|
|
359
|
+
)
|
|
360
|
+
if any(local_artifact_pair) and not all(local_artifact_pair):
|
|
361
|
+
raise ValueError(
|
|
362
|
+
"local artifact directory and base URL must be configured together"
|
|
363
|
+
)
|
|
364
|
+
if all(local_artifact_pair) and self.environment not in {
|
|
365
|
+
RuntimeEnvironment.DEVELOPMENT,
|
|
366
|
+
RuntimeEnvironment.TEST,
|
|
367
|
+
}:
|
|
368
|
+
raise ValueError(
|
|
369
|
+
"local artifact storage is allowed only in development/test"
|
|
370
|
+
)
|
|
371
|
+
if (
|
|
372
|
+
self.local_artifact_directory is not None
|
|
373
|
+
and not Path(self.local_artifact_directory).expanduser().is_absolute()
|
|
374
|
+
):
|
|
375
|
+
raise ValueError("local artifact directory must be an absolute path")
|
|
376
|
+
if (
|
|
377
|
+
self.local_artifact_base_url is not None
|
|
378
|
+
and self.local_artifact_base_url.host
|
|
379
|
+
not in {"localhost", "127.0.0.1", "::1"}
|
|
380
|
+
):
|
|
381
|
+
raise ValueError("local artifact base URL must use a loopback hostname")
|
|
382
|
+
oss_configured = any(
|
|
383
|
+
value is not None
|
|
384
|
+
for value in (
|
|
385
|
+
self.oss_endpoint,
|
|
386
|
+
self.oss_bucket,
|
|
387
|
+
self.oss_access_key_id,
|
|
388
|
+
self.oss_access_key_secret,
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
oss_complete = all(
|
|
392
|
+
value is not None
|
|
393
|
+
for value in (
|
|
394
|
+
self.oss_endpoint,
|
|
395
|
+
self.oss_bucket,
|
|
396
|
+
self.oss_access_key_id,
|
|
397
|
+
self.oss_access_key_secret,
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
if oss_configured and not oss_complete:
|
|
401
|
+
raise ValueError(
|
|
402
|
+
"OSS endpoint, bucket, access-key ID, and access-key secret must be configured together"
|
|
403
|
+
)
|
|
404
|
+
if all(local_artifact_pair) and oss_configured:
|
|
405
|
+
raise ValueError(
|
|
406
|
+
"configure either local artifact storage or private OSS, not both"
|
|
407
|
+
)
|
|
408
|
+
development_egress_pair = (
|
|
409
|
+
self.development_http_proxy_url is not None,
|
|
410
|
+
self.development_dns_over_https_url is not None,
|
|
411
|
+
)
|
|
412
|
+
if any(development_egress_pair) and not all(development_egress_pair):
|
|
413
|
+
raise ValueError(
|
|
414
|
+
"development HTTP proxy and DNS-over-HTTPS URL must be configured together"
|
|
415
|
+
)
|
|
416
|
+
if all(development_egress_pair) and self.environment not in {
|
|
417
|
+
RuntimeEnvironment.DEVELOPMENT,
|
|
418
|
+
RuntimeEnvironment.TEST,
|
|
419
|
+
}:
|
|
420
|
+
raise ValueError(
|
|
421
|
+
"development proxy egress is allowed only in development/test"
|
|
422
|
+
)
|
|
423
|
+
if self.development_http_proxy_url is not None:
|
|
424
|
+
proxy_url = TypeAdapter(AnyHttpUrl).validate_python(
|
|
425
|
+
self.development_http_proxy_url.get_secret_value()
|
|
426
|
+
)
|
|
427
|
+
if proxy_url.scheme != "http":
|
|
428
|
+
raise ValueError("development HTTP proxy must use the http scheme")
|
|
429
|
+
return self
|
|
430
|
+
|
|
431
|
+
@property
|
|
432
|
+
def resolved_artifact_namespace_owner(self) -> str:
|
|
433
|
+
"""Return the explicit deployment owner or an isolated local/test fallback.
|
|
434
|
+
|
|
435
|
+
Steps:
|
|
436
|
+
1. use the stable configured owner in staging and production;
|
|
437
|
+
2. derive a deterministic environment/database owner only for local tests.
|
|
438
|
+
|
|
439
|
+
Side effects if changes:
|
|
440
|
+
- object-store canaries and Mongo namespace markers must receive one value;
|
|
441
|
+
- changing this value requires an explicit artifact-namespace migration.
|
|
442
|
+
"""
|
|
443
|
+
|
|
444
|
+
return self.artifact_namespace_owner or (
|
|
445
|
+
f"{self.environment.value}/{self.mongo_database}"
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
def response_policy(self, policy_class: str) -> ResponsePolicy:
|
|
449
|
+
"""Resolve a finite response policy without caller-controlled limits.
|
|
450
|
+
|
|
451
|
+
Steps:
|
|
452
|
+
1. map the contract enum value to one server byte ceiling;
|
|
453
|
+
2. return only reviewed MIME prefixes for that class.
|
|
454
|
+
|
|
455
|
+
Side effects if changes:
|
|
456
|
+
- fetch streaming and typed content/size denial outcomes.
|
|
457
|
+
"""
|
|
458
|
+
|
|
459
|
+
policies = {
|
|
460
|
+
"DOCUMENT": ResponsePolicy(
|
|
461
|
+
max_bytes=self.document_max_bytes,
|
|
462
|
+
content_type_prefixes=(
|
|
463
|
+
"text/html",
|
|
464
|
+
"text/plain",
|
|
465
|
+
"application/xhtml+xml",
|
|
466
|
+
),
|
|
467
|
+
),
|
|
468
|
+
"JSON": ResponsePolicy(
|
|
469
|
+
max_bytes=self.json_max_bytes,
|
|
470
|
+
content_type_prefixes=(
|
|
471
|
+
"application/json",
|
|
472
|
+
"application/graphql-response+json",
|
|
473
|
+
),
|
|
474
|
+
),
|
|
475
|
+
"SITEMAP": ResponsePolicy(
|
|
476
|
+
max_bytes=self.sitemap_max_bytes,
|
|
477
|
+
content_type_prefixes=("application/xml", "text/xml", "text/plain"),
|
|
478
|
+
),
|
|
479
|
+
"MEDIA": ResponsePolicy(
|
|
480
|
+
max_bytes=self.media_max_bytes,
|
|
481
|
+
content_type_prefixes=("image/", "video/"),
|
|
482
|
+
),
|
|
483
|
+
}
|
|
484
|
+
return policies[policy_class]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Shared Sentry initialization for API, Celery worker, and beat processes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
import sentry_sdk
|
|
8
|
+
from keble_helpers.typings import PydanticModelConfig
|
|
9
|
+
from pydantic import BaseModel, SecretStr
|
|
10
|
+
|
|
11
|
+
from keble_scraper_api.settings import RuntimeEnvironment, ScraperSettings
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TelemetryRuntime(StrEnum):
|
|
15
|
+
"""Finite process roles attached to every scraper Sentry event.
|
|
16
|
+
|
|
17
|
+
Steps:
|
|
18
|
+
1. distinguish request, scheduler, worker-parent, and task-child processes;
|
|
19
|
+
2. provide a stable low-cardinality tag value;
|
|
20
|
+
3. keep queue/job identities out of global initialization.
|
|
21
|
+
|
|
22
|
+
Side effects if changes:
|
|
23
|
+
- Sentry issue grouping and runtime-specific alert routing.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
API = "api"
|
|
27
|
+
CELERY_PARENT = "celery-parent"
|
|
28
|
+
WORKER = "worker"
|
|
29
|
+
BEAT = "beat"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SentryRuntimeConfiguration(BaseModel):
|
|
33
|
+
"""Secret-safe immutable Sentry initialization input for one process role.
|
|
34
|
+
|
|
35
|
+
Steps:
|
|
36
|
+
1. retain the write-only DSN and stable release/environment;
|
|
37
|
+
2. retain the bounded trace sample rate;
|
|
38
|
+
3. retain a finite runtime tag without payload or user data.
|
|
39
|
+
|
|
40
|
+
Side effects if changes:
|
|
41
|
+
- every API/worker/beat event uses these shared telemetry facts.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
model_config = PydanticModelConfig.default(frozen=True, extra="forbid")
|
|
45
|
+
|
|
46
|
+
dsn: SecretStr
|
|
47
|
+
environment: RuntimeEnvironment
|
|
48
|
+
release: str
|
|
49
|
+
traces_sample_rate: float
|
|
50
|
+
runtime: TelemetryRuntime
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_sentry_runtime_configuration(
|
|
54
|
+
*,
|
|
55
|
+
settings: ScraperSettings,
|
|
56
|
+
runtime: TelemetryRuntime,
|
|
57
|
+
) -> SentryRuntimeConfiguration | None:
|
|
58
|
+
"""Build one enabled secret-safe configuration or explicit disabled result.
|
|
59
|
+
|
|
60
|
+
Steps:
|
|
61
|
+
1. treat an absent DSN as telemetry intentionally disabled;
|
|
62
|
+
2. copy only reviewed structural settings into the immutable model;
|
|
63
|
+
3. bind the exact process role for tests and alert routing.
|
|
64
|
+
|
|
65
|
+
Side effects if changes:
|
|
66
|
+
- determines whether process initialization calls the Sentry SDK.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
if settings.sentry_dsn is None:
|
|
70
|
+
return None
|
|
71
|
+
return SentryRuntimeConfiguration(
|
|
72
|
+
dsn=settings.sentry_dsn,
|
|
73
|
+
environment=settings.environment,
|
|
74
|
+
release=settings.sentry_release,
|
|
75
|
+
traces_sample_rate=settings.sentry_traces_sample_rate,
|
|
76
|
+
runtime=runtime,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def configure_sentry(
|
|
81
|
+
*,
|
|
82
|
+
settings: ScraperSettings,
|
|
83
|
+
runtime: TelemetryRuntime,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Initialize Sentry consistently before one process performs real work.
|
|
86
|
+
|
|
87
|
+
Steps:
|
|
88
|
+
1. build the common configuration and return when explicitly disabled;
|
|
89
|
+
2. initialize automatic FastAPI/Celery integrations without default PII;
|
|
90
|
+
3. attach a stable process-role tag for worker/beat crash-loop visibility.
|
|
91
|
+
|
|
92
|
+
Side effects if changes:
|
|
93
|
+
- initializes the process-global Sentry SDK client and runtime tag.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
configuration = build_sentry_runtime_configuration(
|
|
97
|
+
settings=settings,
|
|
98
|
+
runtime=runtime,
|
|
99
|
+
)
|
|
100
|
+
if configuration is None:
|
|
101
|
+
return
|
|
102
|
+
sentry_sdk.init(
|
|
103
|
+
dsn=configuration.dsn.get_secret_value(),
|
|
104
|
+
environment=configuration.environment.value,
|
|
105
|
+
release=configuration.release,
|
|
106
|
+
traces_sample_rate=configuration.traces_sample_rate,
|
|
107
|
+
send_default_pii=False,
|
|
108
|
+
)
|
|
109
|
+
sentry_sdk.set_tag("keble.runtime", configuration.runtime.value)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Celery application imported by worker and beat processes."""
|
|
2
|
+
|
|
3
|
+
from celery.signals import worker_process_init, worker_process_shutdown
|
|
4
|
+
|
|
5
|
+
from keble_scraper_api.infrastructure.celery_factory import build_celery
|
|
6
|
+
from keble_scraper_api.settings import ScraperSettings
|
|
7
|
+
from keble_scraper_api.telemetry import TelemetryRuntime, configure_sentry
|
|
8
|
+
from keble_scraper_api.workers.runtime import (
|
|
9
|
+
initialize_worker_runtime,
|
|
10
|
+
shutdown_worker_runtime,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
settings = ScraperSettings()
|
|
14
|
+
configure_sentry(settings=settings, runtime=TelemetryRuntime.CELERY_PARENT)
|
|
15
|
+
celery_app = build_celery(settings=settings)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@worker_process_init.connect
|
|
19
|
+
def initialize_process(**signal_values: object) -> None:
|
|
20
|
+
"""Create one persistent event loop/container per Celery child process.
|
|
21
|
+
|
|
22
|
+
Steps:
|
|
23
|
+
1. initialize child-local Sentry before dependency startup;
|
|
24
|
+
2. start the persistent event loop and process container;
|
|
25
|
+
3. allow startup faults to reach Celery and Sentry unchanged.
|
|
26
|
+
|
|
27
|
+
Side effects if changes:
|
|
28
|
+
- worker Mongo/Redis pools and startup indexes.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
configure_sentry(settings=settings, runtime=TelemetryRuntime.WORKER)
|
|
32
|
+
initialize_worker_runtime(settings=settings)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@worker_process_shutdown.connect
|
|
36
|
+
def shutdown_process(**signal_values: object) -> None:
|
|
37
|
+
"""Close worker pools and event loop on process shutdown.
|
|
38
|
+
|
|
39
|
+
Steps:
|
|
40
|
+
1. accept the Celery signal payload without dynamic inspection;
|
|
41
|
+
2. close the child runtime exactly once;
|
|
42
|
+
3. leave process-global telemetry to normal interpreter shutdown.
|
|
43
|
+
|
|
44
|
+
Side effects if changes:
|
|
45
|
+
- Celery child resource cleanup.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
shutdown_worker_runtime()
|