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,285 @@
|
|
|
1
|
+
"""HMAC-signed registered callback delivery and durable outbox processing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from uuid import UUID
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
from keble_data_infra_contract import parse_retry_after
|
|
13
|
+
from pydantic import SecretStr
|
|
14
|
+
|
|
15
|
+
from keble_scraper_contract import (
|
|
16
|
+
ScrapeCallbackIdentity,
|
|
17
|
+
ScrapeCompletionEvent,
|
|
18
|
+
ScrapeWebhookHeaders,
|
|
19
|
+
utc_now,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from keble_scraper_api.errors import ScraperDomainError, ScraperErrorCode
|
|
23
|
+
from keble_scraper_api.application.scheduling import ceil_persistence_millisecond
|
|
24
|
+
from keble_scraper_api.models import (
|
|
25
|
+
CallbackTransportDisposition,
|
|
26
|
+
CallbackTransportResult,
|
|
27
|
+
)
|
|
28
|
+
from keble_scraper_api.protocols import (
|
|
29
|
+
CompletionCallbackProtocol,
|
|
30
|
+
OutboxRepositoryProtocol,
|
|
31
|
+
)
|
|
32
|
+
from keble_scraper_api.settings import ScraperSettings
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CallbackDeliveryOutcome(StrEnum):
|
|
36
|
+
"""One outbox lease result without conflating retry with queue exhaustion.
|
|
37
|
+
|
|
38
|
+
Side effects if changes:
|
|
39
|
+
- callback batch continuation and Sentry delivery counters.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
EMPTY = "EMPTY"
|
|
43
|
+
DELIVERED = "DELIVERED"
|
|
44
|
+
RESCHEDULED = "RESCHEDULED"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class WebhookSigner:
|
|
48
|
+
"""Sign timestamp, event identity, and exact body digest with HMAC-SHA256.
|
|
49
|
+
|
|
50
|
+
Steps:
|
|
51
|
+
1. hash the exact serialized callback body;
|
|
52
|
+
2. bind timestamp, event identity, and digest into one message;
|
|
53
|
+
3. authenticate that message with the independent callback key.
|
|
54
|
+
|
|
55
|
+
Side effects if changes:
|
|
56
|
+
- Shopify callback verification and current/next key rotation.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(self, *, key: SecretStr) -> None:
|
|
60
|
+
"""Bind one current signing key independent from service API keys.
|
|
61
|
+
|
|
62
|
+
Side effects if changes:
|
|
63
|
+
- all newly delivered callback signatures.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
self._key = key
|
|
67
|
+
|
|
68
|
+
def sign(
|
|
69
|
+
self, *, event_id: UUID, body: bytes, timestamp: int
|
|
70
|
+
) -> ScrapeWebhookHeaders:
|
|
71
|
+
"""Build stable signature headers over the exact serialized body.
|
|
72
|
+
|
|
73
|
+
Steps:
|
|
74
|
+
1. compute the exact body SHA-256 digest;
|
|
75
|
+
2. assemble the replay-bound canonical message;
|
|
76
|
+
3. return versioned HMAC headers without exposing key material.
|
|
77
|
+
|
|
78
|
+
Side effects if changes:
|
|
79
|
+
- webhook acceptance and replay identity.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
digest = hashlib.sha256(body).hexdigest()
|
|
83
|
+
message = f"{timestamp}.{event_id}.{digest}".encode()
|
|
84
|
+
signature = hmac.new(
|
|
85
|
+
self._key.get_secret_value().encode(),
|
|
86
|
+
message,
|
|
87
|
+
hashlib.sha256,
|
|
88
|
+
).hexdigest()
|
|
89
|
+
return ScrapeWebhookHeaders(
|
|
90
|
+
event_id=event_id,
|
|
91
|
+
timestamp=timestamp,
|
|
92
|
+
signature=f"v1={signature}",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class RegisteredHttpCompletionCallback:
|
|
97
|
+
"""Deliver callbacks only to deployment-registered audience URLs.
|
|
98
|
+
|
|
99
|
+
Steps:
|
|
100
|
+
1. map finite audiences to deployment-owned destinations;
|
|
101
|
+
2. sign exact event bytes and reuse one pooled HTTP client;
|
|
102
|
+
3. return typed HTTP dispositions without exception laundering.
|
|
103
|
+
|
|
104
|
+
Side effects if changes:
|
|
105
|
+
- outbound Shopify webhook calls; caller URLs remain impossible.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self, *, settings: ScraperSettings, signer: WebhookSigner) -> None:
|
|
109
|
+
"""Bind callback registry and independent HMAC signer.
|
|
110
|
+
|
|
111
|
+
Steps:
|
|
112
|
+
1. retain immutable destination and timeout settings;
|
|
113
|
+
2. retain the independent webhook signer;
|
|
114
|
+
3. create one process-owned pooled HTTP client.
|
|
115
|
+
|
|
116
|
+
Side effects if changes:
|
|
117
|
+
- callback destinations and credentials.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
self._settings = settings
|
|
121
|
+
self._signer = signer
|
|
122
|
+
self._client = httpx.AsyncClient(
|
|
123
|
+
timeout=self._settings.read_timeout_seconds,
|
|
124
|
+
trust_env=False,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
async def close(self) -> None:
|
|
128
|
+
"""Close the process-owned registered-callback connection pool.
|
|
129
|
+
|
|
130
|
+
Steps:
|
|
131
|
+
1. drain and close pooled keep-alive connections;
|
|
132
|
+
2. leave signing keys and destination settings untouched;
|
|
133
|
+
3. remain safe after a process made no callback request.
|
|
134
|
+
|
|
135
|
+
Side effects if changes:
|
|
136
|
+
- API/worker shutdown and callback socket cleanup.
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
await self._client.aclose()
|
|
140
|
+
|
|
141
|
+
async def deliver(self, *, event: ScrapeCompletionEvent) -> CallbackTransportResult:
|
|
142
|
+
"""POST one exact signed event and classify its HTTP response.
|
|
143
|
+
|
|
144
|
+
Steps:
|
|
145
|
+
1. select only the deployment-registered audience destination;
|
|
146
|
+
2. sign and send the exact event through the pooled client;
|
|
147
|
+
3. return finite success/retry/permanent HTTP evidence while allowing
|
|
148
|
+
client and implementation exceptions to reach supervision.
|
|
149
|
+
|
|
150
|
+
Side effects if changes:
|
|
151
|
+
- external webhook call and downstream refresh-run transition.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
destinations = {
|
|
155
|
+
ScrapeCallbackIdentity.SHOPIFY_INDEX: self._settings.shopify_callback_url,
|
|
156
|
+
}
|
|
157
|
+
destination = destinations[event.audience]
|
|
158
|
+
if destination is None:
|
|
159
|
+
raise ScraperDomainError(
|
|
160
|
+
code=ScraperErrorCode.CONFIGURATION_INVALID,
|
|
161
|
+
message="callback audience is not configured",
|
|
162
|
+
)
|
|
163
|
+
body = event.model_dump_json(by_alias=True).encode()
|
|
164
|
+
timestamp = int(utc_now().timestamp())
|
|
165
|
+
signed = self._signer.sign(
|
|
166
|
+
event_id=event.event_id,
|
|
167
|
+
body=body,
|
|
168
|
+
timestamp=timestamp,
|
|
169
|
+
)
|
|
170
|
+
response = await self._client.post(
|
|
171
|
+
str(destination),
|
|
172
|
+
content=body,
|
|
173
|
+
headers={
|
|
174
|
+
"Content-Type": "application/json",
|
|
175
|
+
"X-Keble-Event-ID": str(signed.event_id),
|
|
176
|
+
"X-Keble-Timestamp": str(signed.timestamp),
|
|
177
|
+
"X-Keble-Signature": signed.signature,
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
observed_at = utc_now()
|
|
181
|
+
retry_at = parse_retry_after(
|
|
182
|
+
value=response.headers.get("Retry-After"),
|
|
183
|
+
observed_at=observed_at,
|
|
184
|
+
)
|
|
185
|
+
if response.is_success:
|
|
186
|
+
disposition = CallbackTransportDisposition.DELIVERED
|
|
187
|
+
elif (
|
|
188
|
+
response.status_code in {408, 409, 425, 429} or response.status_code >= 500
|
|
189
|
+
):
|
|
190
|
+
disposition = CallbackTransportDisposition.RETRYABLE
|
|
191
|
+
else:
|
|
192
|
+
disposition = CallbackTransportDisposition.PERMANENT
|
|
193
|
+
return CallbackTransportResult(
|
|
194
|
+
disposition=disposition,
|
|
195
|
+
status_code=response.status_code,
|
|
196
|
+
retry_at=(
|
|
197
|
+
retry_at
|
|
198
|
+
if disposition is CallbackTransportDisposition.RETRYABLE
|
|
199
|
+
else None
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class CallbackOutboxService:
|
|
205
|
+
"""Lease/deliver/reschedule signed callbacks from the durable outbox.
|
|
206
|
+
|
|
207
|
+
Steps:
|
|
208
|
+
1. lease one due event under worker ownership;
|
|
209
|
+
2. deliver through the typed registered transport;
|
|
210
|
+
3. acknowledge, reschedule, or dead-letter under immutable row limits.
|
|
211
|
+
|
|
212
|
+
Side effects if changes:
|
|
213
|
+
- webhook retries, dead letters, and at-least-once delivery timing.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
def __init__(
|
|
217
|
+
self,
|
|
218
|
+
*,
|
|
219
|
+
repository: OutboxRepositoryProtocol,
|
|
220
|
+
callback: CompletionCallbackProtocol,
|
|
221
|
+
lease_seconds: int,
|
|
222
|
+
retry_base_seconds: int,
|
|
223
|
+
) -> None:
|
|
224
|
+
"""Bind outbox persistence, transport, lease, and retry cadence.
|
|
225
|
+
|
|
226
|
+
Steps:
|
|
227
|
+
1. retain the callback repository and registered transport;
|
|
228
|
+
2. retain the lease duration for physical delivery ownership;
|
|
229
|
+
3. retain only retry cadence because each row owns its immutable limit.
|
|
230
|
+
|
|
231
|
+
Side effects if changes:
|
|
232
|
+
- callback worker concurrency and terminal delivery behavior.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
self._repository = repository
|
|
236
|
+
self._callback = callback
|
|
237
|
+
self._lease_seconds = lease_seconds
|
|
238
|
+
self._retry_base_seconds = retry_base_seconds
|
|
239
|
+
|
|
240
|
+
async def deliver_one(self, *, worker_id: str) -> CallbackDeliveryOutcome:
|
|
241
|
+
"""Deliver at most one due event and persist its next state.
|
|
242
|
+
|
|
243
|
+
Steps:
|
|
244
|
+
1. lease one due row with an immutable attempt budget;
|
|
245
|
+
2. acknowledge typed HTTP success or compute bounded receiver backoff;
|
|
246
|
+
3. dead-letter permanent HTTP rejection or an exhausted retry budget;
|
|
247
|
+
4. allow transport/runtime exceptions to leave the lease recoverable.
|
|
248
|
+
|
|
249
|
+
Side effects if changes:
|
|
250
|
+
- one external callback call and one outbox transition.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
row = await self._repository.lease_callback_due(
|
|
254
|
+
worker_id=worker_id,
|
|
255
|
+
now=utc_now(),
|
|
256
|
+
lease_seconds=self._lease_seconds,
|
|
257
|
+
)
|
|
258
|
+
if row is None:
|
|
259
|
+
return CallbackDeliveryOutcome.EMPTY
|
|
260
|
+
delivered = await self._callback.deliver(event=row.event)
|
|
261
|
+
if delivered.disposition is CallbackTransportDisposition.DELIVERED:
|
|
262
|
+
await self._repository.mark_delivered(
|
|
263
|
+
event_id=row.event.event_id,
|
|
264
|
+
worker_id=worker_id,
|
|
265
|
+
delivered_at=utc_now(),
|
|
266
|
+
)
|
|
267
|
+
return CallbackDeliveryOutcome.DELIVERED
|
|
268
|
+
dead_letter = (
|
|
269
|
+
delivered.disposition is CallbackTransportDisposition.PERMANENT
|
|
270
|
+
or row.attempt_count >= row.attempt_limit
|
|
271
|
+
)
|
|
272
|
+
delay = self._retry_base_seconds * (2 ** max(0, row.attempt_count - 1))
|
|
273
|
+
local_next_attempt_at = utc_now() + timedelta(seconds=delay)
|
|
274
|
+
await self._repository.reschedule(
|
|
275
|
+
event_id=row.event.event_id,
|
|
276
|
+
worker_id=worker_id,
|
|
277
|
+
next_attempt_at=ceil_persistence_millisecond(
|
|
278
|
+
value=max(
|
|
279
|
+
local_next_attempt_at,
|
|
280
|
+
delivered.retry_at or local_next_attempt_at,
|
|
281
|
+
)
|
|
282
|
+
),
|
|
283
|
+
dead_letter=dead_letter,
|
|
284
|
+
)
|
|
285
|
+
return CallbackDeliveryOutcome.RESCHEDULED
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Settings-owned Celery queue and beat composition."""
|
|
2
|
+
|
|
3
|
+
from celery import Celery
|
|
4
|
+
|
|
5
|
+
from keble_scraper_api.settings import ScraperSettings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_celery(*, settings: ScraperSettings) -> Celery:
|
|
9
|
+
"""Build one RabbitMQ-only Celery app with isolated queues and schedules.
|
|
10
|
+
|
|
11
|
+
Side effects if changes:
|
|
12
|
+
- task acknowledgement, queue routing, retry recovery, and beat cadence.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
app = Celery(
|
|
16
|
+
"keble-scraper",
|
|
17
|
+
broker=settings.rabbitmq_uri.get_secret_value(),
|
|
18
|
+
include=["keble_scraper_api.workers.tasks"],
|
|
19
|
+
)
|
|
20
|
+
app.conf.update(
|
|
21
|
+
task_serializer="json",
|
|
22
|
+
accept_content=["json"],
|
|
23
|
+
result_backend=None,
|
|
24
|
+
task_ignore_result=True,
|
|
25
|
+
task_acks_late=True,
|
|
26
|
+
task_reject_on_worker_lost=True,
|
|
27
|
+
worker_prefetch_multiplier=1,
|
|
28
|
+
broker_connection_retry_on_startup=True,
|
|
29
|
+
timezone="UTC",
|
|
30
|
+
enable_utc=True,
|
|
31
|
+
task_routes={
|
|
32
|
+
"keble_scraper.execute_job": {
|
|
33
|
+
"queue": settings.fetch_queue,
|
|
34
|
+
},
|
|
35
|
+
"keble_scraper.run_maintenance": {
|
|
36
|
+
"queue": settings.maintenance_queue,
|
|
37
|
+
},
|
|
38
|
+
"keble_scraper.deliver_callbacks": {
|
|
39
|
+
"queue": settings.maintenance_queue,
|
|
40
|
+
},
|
|
41
|
+
"keble_scraper.check_proxies": {
|
|
42
|
+
"queue": settings.proxy_health_queue,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
beat_schedule={
|
|
46
|
+
"scraper-maintenance-every-minute": {
|
|
47
|
+
"task": "keble_scraper.run_maintenance",
|
|
48
|
+
"schedule": 60.0,
|
|
49
|
+
"options": {"queue": settings.maintenance_queue},
|
|
50
|
+
},
|
|
51
|
+
"scraper-callbacks-every-fifteen-seconds": {
|
|
52
|
+
"task": "keble_scraper.deliver_callbacks",
|
|
53
|
+
"schedule": 15.0,
|
|
54
|
+
"options": {"queue": settings.maintenance_queue},
|
|
55
|
+
},
|
|
56
|
+
"scraper-proxy-health-every-five-minutes": {
|
|
57
|
+
"task": "keble_scraper.check_proxies",
|
|
58
|
+
"schedule": 300.0,
|
|
59
|
+
"options": {"queue": settings.proxy_health_queue},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
return app
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Cloudflare Browser Rendering exception transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC, datetime, timedelta
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
from time import monotonic
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from keble_data_infra_contract import parse_retry_after
|
|
12
|
+
from keble_helpers.typings import PydanticModelConfig
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
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.settings import ResponsePolicy, ScraperSettings
|
|
23
|
+
|
|
24
|
+
from .url_policy import PublicUrlPolicy
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CloudflareApiError(BaseModel):
|
|
28
|
+
"""One documented Cloudflare API error item without response-body leakage.
|
|
29
|
+
|
|
30
|
+
Steps:
|
|
31
|
+
1. retain the provider-native numeric code;
|
|
32
|
+
2. validate but never propagate the provider message;
|
|
33
|
+
3. expose only the code to typed failure evidence.
|
|
34
|
+
|
|
35
|
+
Side effects if changes:
|
|
36
|
+
- Browser Run rate-limit classification and operator diagnostics.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
model_config = PydanticModelConfig.default(frozen=True, extra="ignore")
|
|
40
|
+
|
|
41
|
+
code: int
|
|
42
|
+
message: str = Field(min_length=1, max_length=1_024)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class CloudflareApiErrorEnvelope(BaseModel):
|
|
46
|
+
"""Documented non-success envelope returned by Cloudflare Browser Run.
|
|
47
|
+
|
|
48
|
+
Steps:
|
|
49
|
+
1. require Cloudflare's explicit unsuccessful marker;
|
|
50
|
+
2. require at least one typed native error;
|
|
51
|
+
3. keep the raw payload outside logs and public contracts.
|
|
52
|
+
|
|
53
|
+
Side effects if changes:
|
|
54
|
+
- malformed vendor responses escape to Sentry instead of becoming rate/payment facts.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
model_config = PydanticModelConfig.default(frozen=True, extra="ignore")
|
|
58
|
+
|
|
59
|
+
success: Literal[False]
|
|
60
|
+
errors: tuple[CloudflareApiError, ...] = Field(min_length=1)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class CloudflareBrowserFetcher:
|
|
64
|
+
"""Render reviewed exception pages through Cloudflare's content endpoint.
|
|
65
|
+
|
|
66
|
+
Steps:
|
|
67
|
+
1. enforce local URL/domain/credential policy before vendor work;
|
|
68
|
+
2. reuse one pooled Browser Rendering client;
|
|
69
|
+
3. return bounded content or typed native capacity evidence.
|
|
70
|
+
|
|
71
|
+
Side effects if changes:
|
|
72
|
+
- browser vendor spend, rendered artifacts, and rate-limit accounting.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self, *, settings: ScraperSettings, url_policy: PublicUrlPolicy
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Bind credentials and the same pre-dispatch public URL policy.
|
|
79
|
+
|
|
80
|
+
Steps:
|
|
81
|
+
1. retain immutable renderer settings and URL policy;
|
|
82
|
+
2. construct one process-owned client without ambient proxy trust;
|
|
83
|
+
3. defer vendor calls until an allowlisted request arrives.
|
|
84
|
+
|
|
85
|
+
Side effects if changes:
|
|
86
|
+
- Cloudflare availability and SSRF validation before remote rendering.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
self._settings = settings
|
|
90
|
+
self._url_policy = url_policy
|
|
91
|
+
self._client = httpx.AsyncClient(
|
|
92
|
+
timeout=self._settings.read_timeout_seconds,
|
|
93
|
+
trust_env=False,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
async def close(self) -> None:
|
|
97
|
+
"""Close the pooled Cloudflare HTTP transport.
|
|
98
|
+
|
|
99
|
+
Steps:
|
|
100
|
+
1. close keep-alive connections owned by this fetcher;
|
|
101
|
+
2. remain safe when called after no vendor request;
|
|
102
|
+
3. leave credential/settings ownership unchanged.
|
|
103
|
+
|
|
104
|
+
Side effects if changes:
|
|
105
|
+
- API/worker shutdown and Cloudflare socket cleanup.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
await self._client.aclose()
|
|
109
|
+
|
|
110
|
+
async def fetch(
|
|
111
|
+
self, *, request: PreparedFetch, response_policy: ResponsePolicy
|
|
112
|
+
) -> FetchResult:
|
|
113
|
+
"""Validate, render, parse capacity evidence, and bound returned HTML.
|
|
114
|
+
|
|
115
|
+
Steps:
|
|
116
|
+
1. validate public target plus explicit domain/credential gates;
|
|
117
|
+
2. issue one pooled Browser Run request and parse shared Retry-After evidence;
|
|
118
|
+
3. retain documented native error codes without inferring payment;
|
|
119
|
+
4. enforce the response byte ceiling before returning typed evidence.
|
|
120
|
+
|
|
121
|
+
Side effects if changes:
|
|
122
|
+
- actual Browser Rendering calls and billed browser duration.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
target = await self._url_policy.validate_and_resolve(url=request.url)
|
|
126
|
+
if not self._domain_allowed(hostname=target.hostname):
|
|
127
|
+
return FetchResult(
|
|
128
|
+
outcome=ScrapeAttemptOutcome.DENIED,
|
|
129
|
+
route=ScrapeTransportRoute.CLOUDFLARE_BROWSER_RUN,
|
|
130
|
+
trust=ScrapeArtifactTrust.VERIFIED,
|
|
131
|
+
final_url=request.url,
|
|
132
|
+
elapsed_ms=0,
|
|
133
|
+
error_code="URL_DENIED",
|
|
134
|
+
observed_at=datetime.now(UTC),
|
|
135
|
+
)
|
|
136
|
+
if (
|
|
137
|
+
self._settings.cloudflare_account_id is None
|
|
138
|
+
or self._settings.cloudflare_api_token is None
|
|
139
|
+
):
|
|
140
|
+
return self._failed(
|
|
141
|
+
url=request.url, error_code="ROUTE_UNAVAILABLE", elapsed_ms=0
|
|
142
|
+
)
|
|
143
|
+
endpoint = (
|
|
144
|
+
"https://api.cloudflare.com/client/v4/accounts/"
|
|
145
|
+
f"{self._settings.cloudflare_account_id}/browser-rendering/content"
|
|
146
|
+
)
|
|
147
|
+
started = monotonic()
|
|
148
|
+
raw = await self._client.post(
|
|
149
|
+
endpoint,
|
|
150
|
+
headers={
|
|
151
|
+
"Authorization": (
|
|
152
|
+
"Bearer " + self._settings.cloudflare_api_token.get_secret_value()
|
|
153
|
+
)
|
|
154
|
+
},
|
|
155
|
+
json={
|
|
156
|
+
"url": request.url,
|
|
157
|
+
"gotoOptions": {"waitUntil": "networkidle0"},
|
|
158
|
+
"rejectResourceTypes": ["stylesheet", "font"],
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
elapsed = max(0.0, monotonic() - started)
|
|
162
|
+
elapsed_ms = int(elapsed * 1000)
|
|
163
|
+
observed_at = datetime.now(UTC)
|
|
164
|
+
retry_at = parse_retry_after(
|
|
165
|
+
value=raw.headers.get("Retry-After"),
|
|
166
|
+
observed_at=observed_at,
|
|
167
|
+
)
|
|
168
|
+
if raw.status_code == 429 and retry_at is None:
|
|
169
|
+
retry_at = observed_at + timedelta(
|
|
170
|
+
seconds=self._settings.upstream_rate_limit_default_seconds
|
|
171
|
+
)
|
|
172
|
+
upstream_code = self._upstream_code(response=raw)
|
|
173
|
+
body = raw.content
|
|
174
|
+
if len(body) > response_policy.max_bytes:
|
|
175
|
+
return FetchResult(
|
|
176
|
+
outcome=ScrapeAttemptOutcome.SIZE_LIMIT,
|
|
177
|
+
route=ScrapeTransportRoute.CLOUDFLARE_BROWSER_RUN,
|
|
178
|
+
trust=ScrapeArtifactTrust.VERIFIED,
|
|
179
|
+
final_url=request.url,
|
|
180
|
+
status_code=raw.status_code,
|
|
181
|
+
content_type="text/html",
|
|
182
|
+
elapsed_ms=elapsed_ms,
|
|
183
|
+
browser_seconds=Decimal(str(round(elapsed, 3))),
|
|
184
|
+
error_code="RESPONSE_TOO_LARGE",
|
|
185
|
+
upstream_code=upstream_code,
|
|
186
|
+
retry_at=retry_at,
|
|
187
|
+
observed_at=observed_at,
|
|
188
|
+
)
|
|
189
|
+
outcome = (
|
|
190
|
+
ScrapeAttemptOutcome.SUCCEEDED
|
|
191
|
+
if raw.is_success
|
|
192
|
+
else ScrapeAttemptOutcome.HTTP_ERROR
|
|
193
|
+
)
|
|
194
|
+
return FetchResult(
|
|
195
|
+
outcome=outcome,
|
|
196
|
+
route=ScrapeTransportRoute.CLOUDFLARE_BROWSER_RUN,
|
|
197
|
+
trust=ScrapeArtifactTrust.VERIFIED,
|
|
198
|
+
final_url=request.url,
|
|
199
|
+
status_code=raw.status_code,
|
|
200
|
+
content_type="text/html",
|
|
201
|
+
body=body,
|
|
202
|
+
elapsed_ms=elapsed_ms,
|
|
203
|
+
browser_seconds=Decimal(str(round(elapsed, 3))),
|
|
204
|
+
error_code=None
|
|
205
|
+
if outcome is ScrapeAttemptOutcome.SUCCEEDED
|
|
206
|
+
else "HTTP_ERROR",
|
|
207
|
+
upstream_code=upstream_code,
|
|
208
|
+
retry_at=retry_at,
|
|
209
|
+
observed_at=observed_at,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
@staticmethod
|
|
213
|
+
def _upstream_code(*, response: httpx.Response) -> str | None:
|
|
214
|
+
"""Extract a documented native code from a rate-limit envelope.
|
|
215
|
+
|
|
216
|
+
Steps:
|
|
217
|
+
1. leave success and non-429 HTTP classification to status evidence;
|
|
218
|
+
2. validate the documented 429 JSON envelope with Pydantic;
|
|
219
|
+
3. retain only the first bounded numeric code as ``CF_<code>``.
|
|
220
|
+
|
|
221
|
+
Side effects if changes:
|
|
222
|
+
- Cloudflare code 2001 remains rate evidence, never payment evidence;
|
|
223
|
+
- malformed 429 envelopes escape for Sentry/supervision.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
if response.status_code != 429:
|
|
227
|
+
return None
|
|
228
|
+
envelope = CloudflareApiErrorEnvelope.model_validate_json(response.content)
|
|
229
|
+
return f"CF_{envelope.errors[0].code}"
|
|
230
|
+
|
|
231
|
+
def _domain_allowed(self, *, hostname: str) -> bool:
|
|
232
|
+
"""Require an exact or subdomain match against reviewed browser targets.
|
|
233
|
+
|
|
234
|
+
Side effects if changes:
|
|
235
|
+
- remote browser SSRF exposure and eligible exception-store domains.
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
return any(
|
|
239
|
+
hostname == domain or hostname.endswith(f".{domain}")
|
|
240
|
+
for domain in self._settings.cloudflare_browser_domain_allowlist
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
@staticmethod
|
|
244
|
+
def _failed(*, url: str, error_code: str, elapsed_ms: int) -> FetchResult:
|
|
245
|
+
"""Build one sanitized renderer failure.
|
|
246
|
+
|
|
247
|
+
Side effects if changes:
|
|
248
|
+
- renderer retry and terminal gap reasons.
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
return FetchResult(
|
|
252
|
+
outcome=ScrapeAttemptOutcome.NETWORK_ERROR,
|
|
253
|
+
route=ScrapeTransportRoute.CLOUDFLARE_BROWSER_RUN,
|
|
254
|
+
trust=ScrapeArtifactTrust.VERIFIED,
|
|
255
|
+
final_url=url,
|
|
256
|
+
elapsed_ms=elapsed_ms,
|
|
257
|
+
error_code=error_code,
|
|
258
|
+
observed_at=datetime.now(UTC),
|
|
259
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Encrypted-at-rest proxy credential conversion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib.parse import quote, urlsplit
|
|
6
|
+
|
|
7
|
+
from cryptography.fernet import Fernet
|
|
8
|
+
from pydantic import SecretStr
|
|
9
|
+
|
|
10
|
+
from keble_scraper_contract import ProxyCandidateCreate
|
|
11
|
+
|
|
12
|
+
from keble_scraper_api.errors import ScraperDomainError, ScraperErrorCode
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProxyCredentialCipher:
|
|
16
|
+
"""Encrypt complete proxy URLs and expose only redacted operational values.
|
|
17
|
+
|
|
18
|
+
Side effects if changes:
|
|
19
|
+
- Mongo proxy secrets and actual experimental proxy egress.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, *, fernet_key: SecretStr) -> None:
|
|
23
|
+
"""Construct one process cipher from secret-system material.
|
|
24
|
+
|
|
25
|
+
Side effects if changes:
|
|
26
|
+
- old proxy candidates must remain decryptable across deployments.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
self._fernet = Fernet(fernet_key.get_secret_value().encode())
|
|
30
|
+
|
|
31
|
+
def encode(self, *, command: ProxyCandidateCreate) -> tuple[str, str]:
|
|
32
|
+
"""Validate an endpoint, attach credentials, and return redacted/ciphertext.
|
|
33
|
+
|
|
34
|
+
Side effects if changes:
|
|
35
|
+
- admin imports can create future outbound network routes.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
parsed = urlsplit(command.endpoint)
|
|
39
|
+
if (
|
|
40
|
+
parsed.scheme not in {"http", "https"}
|
|
41
|
+
or parsed.hostname is None
|
|
42
|
+
or parsed.username is not None
|
|
43
|
+
or parsed.password is not None
|
|
44
|
+
or parsed.query
|
|
45
|
+
or parsed.fragment
|
|
46
|
+
or parsed.path not in {"", "/"}
|
|
47
|
+
):
|
|
48
|
+
raise ScraperDomainError(
|
|
49
|
+
code=ScraperErrorCode.URL_DENIED,
|
|
50
|
+
message="proxy endpoint must be a credential-free HTTP(S) origin",
|
|
51
|
+
)
|
|
52
|
+
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
|
53
|
+
credentials = ""
|
|
54
|
+
if command.username is not None:
|
|
55
|
+
password = (
|
|
56
|
+
command.password.get_secret_value()
|
|
57
|
+
if command.password is not None
|
|
58
|
+
else ""
|
|
59
|
+
)
|
|
60
|
+
credentials = (
|
|
61
|
+
f"{quote(command.username, safe='')}:{quote(password, safe='')}@"
|
|
62
|
+
)
|
|
63
|
+
complete = f"{parsed.scheme}://{credentials}{parsed.hostname}:{port}"
|
|
64
|
+
redacted = f"{parsed.scheme}://{parsed.hostname}:{port}"
|
|
65
|
+
encrypted = self._fernet.encrypt(complete.encode()).decode()
|
|
66
|
+
return redacted, encrypted
|
|
67
|
+
|
|
68
|
+
def decode(self, *, encrypted_proxy_url: str) -> SecretStr:
|
|
69
|
+
"""Decrypt one route only at dispatch time.
|
|
70
|
+
|
|
71
|
+
Side effects if changes:
|
|
72
|
+
- proxy credentials enter the HTTP client's in-memory request config.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
return SecretStr(self._fernet.decrypt(encrypted_proxy_url.encode()).decode())
|