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,126 @@
|
|
|
1
|
+
"""Quarantined public-proxy health checking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
|
|
7
|
+
from keble_scraper_contract import (
|
|
8
|
+
ProxyHealthObservation,
|
|
9
|
+
ScrapeTransportRoute,
|
|
10
|
+
utc_now,
|
|
11
|
+
)
|
|
12
|
+
from keble_helpers.typings import PydanticModelConfig
|
|
13
|
+
from pydantic import BaseModel, IPvAnyAddress
|
|
14
|
+
|
|
15
|
+
from keble_scraper_api.application.fetching import FetchResult, PreparedFetch
|
|
16
|
+
from keble_scraper_api.models import RouteReservation
|
|
17
|
+
from keble_scraper_api.protocols import HttpFetcherProtocol, ProxyRepositoryProtocol
|
|
18
|
+
from keble_scraper_api.settings import ScraperSettings
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ProxyHealthPayload(BaseModel):
|
|
22
|
+
"""Validated egress identity returned by the fixed health endpoint.
|
|
23
|
+
|
|
24
|
+
Side effects if changes:
|
|
25
|
+
- proxy health evidence and operator diagnostics.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
model_config = PydanticModelConfig.default(frozen=True, extra="ignore")
|
|
29
|
+
|
|
30
|
+
ip: IPvAnyAddress
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ProxyHealthService:
|
|
34
|
+
"""Test quarantined proxies against one fixed public TLS endpoint.
|
|
35
|
+
|
|
36
|
+
Steps:
|
|
37
|
+
1. inspect only a bounded set of due quarantined candidates;
|
|
38
|
+
2. use the shared secure fetcher against a fixed endpoint;
|
|
39
|
+
3. persist typed health evidence only after response validation.
|
|
40
|
+
|
|
41
|
+
Side effects if changes:
|
|
42
|
+
- experimental proxy traffic and promotion/decay state.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
repository: ProxyRepositoryProtocol,
|
|
49
|
+
fetcher: HttpFetcherProtocol,
|
|
50
|
+
settings: ScraperSettings,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Bind proxy persistence and the same secure HTTP transport.
|
|
53
|
+
|
|
54
|
+
Side effects if changes:
|
|
55
|
+
- all proxy-health worker runs.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
self._repository = repository
|
|
59
|
+
self._fetcher = fetcher
|
|
60
|
+
self._settings = settings
|
|
61
|
+
|
|
62
|
+
async def run_due(self, *, limit: int = 100) -> int:
|
|
63
|
+
"""Check due candidates sequentially and append bounded observations.
|
|
64
|
+
|
|
65
|
+
Steps:
|
|
66
|
+
1. load bounded due candidates and their secret credentials;
|
|
67
|
+
2. fetch the fixed endpoint while allowing implementation faults to supervise;
|
|
68
|
+
3. record only typed HTTP/payload outcomes as health evidence.
|
|
69
|
+
|
|
70
|
+
Side effects if changes:
|
|
71
|
+
- public network calls and candidate health state updates.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
candidates = await self._repository.due_candidates(now=utc_now(), limit=limit)
|
|
75
|
+
for candidate in candidates:
|
|
76
|
+
credential = await self._repository.credential_for(
|
|
77
|
+
candidate_id=candidate.candidate_id
|
|
78
|
+
)
|
|
79
|
+
request = PreparedFetch(
|
|
80
|
+
url="https://api.ipify.org/?format=json",
|
|
81
|
+
method="GET",
|
|
82
|
+
headers={"Accept": "application/json"},
|
|
83
|
+
request_identity=f"proxy-health:{candidate.candidate_id}",
|
|
84
|
+
)
|
|
85
|
+
result = await self._fetcher.fetch(
|
|
86
|
+
request=request,
|
|
87
|
+
reservation=RouteReservation(
|
|
88
|
+
route=ScrapeTransportRoute.PUBLIC_POOL_EXPERIMENT,
|
|
89
|
+
proxy_url=credential,
|
|
90
|
+
),
|
|
91
|
+
response_policy=self._settings.response_policy("JSON"),
|
|
92
|
+
)
|
|
93
|
+
parsed = await self._parse_payload(result=result)
|
|
94
|
+
succeeded = parsed is not None
|
|
95
|
+
observation = ProxyHealthObservation(
|
|
96
|
+
succeeded=succeeded,
|
|
97
|
+
latency_ms=result.elapsed_ms,
|
|
98
|
+
egress_ip=str(parsed.ip) if parsed is not None else None,
|
|
99
|
+
tls_valid=succeeded,
|
|
100
|
+
)
|
|
101
|
+
await self._repository.record_health(
|
|
102
|
+
candidate_id=candidate.candidate_id,
|
|
103
|
+
observation=observation,
|
|
104
|
+
next_health_at=utc_now() + timedelta(minutes=15),
|
|
105
|
+
)
|
|
106
|
+
return len(candidates)
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
async def _parse_payload(
|
|
110
|
+
*,
|
|
111
|
+
result: FetchResult,
|
|
112
|
+
) -> ProxyHealthPayload | None:
|
|
113
|
+
"""Accept only a successful typed response containing one valid IP.
|
|
114
|
+
|
|
115
|
+
Steps:
|
|
116
|
+
1. treat a finite non-success transport result as failed health evidence;
|
|
117
|
+
2. validate a success body through the strict Pydantic payload;
|
|
118
|
+
3. let malformed success bodies surface as provider/implementation faults.
|
|
119
|
+
|
|
120
|
+
Side effects if changes:
|
|
121
|
+
- proxy promotion and recorded egress identity.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
if not result.succeeded:
|
|
125
|
+
return None
|
|
126
|
+
return ProxyHealthPayload.model_validate_json(result.body)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Durable scheduling precision shared by jobs, failures, and callbacks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def ceil_persistence_millisecond(*, value: datetime) -> datetime:
|
|
9
|
+
"""Round one due horizon upward to MongoDB BSON millisecond precision.
|
|
10
|
+
|
|
11
|
+
Steps:
|
|
12
|
+
1. retain timestamps already aligned to an exact millisecond;
|
|
13
|
+
2. add only the missing microseconds to reach the next millisecond;
|
|
14
|
+
3. preserve timezone information and never schedule before the input horizon.
|
|
15
|
+
|
|
16
|
+
Side effects if changes:
|
|
17
|
+
- job and callback retries must not run before provider/local wait horizons;
|
|
18
|
+
- append-only failure evidence must equal its persisted scheduling precision.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
remainder = value.microsecond % 1_000
|
|
22
|
+
if remainder == 0:
|
|
23
|
+
return value
|
|
24
|
+
return value + timedelta(microseconds=1_000 - remainder)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Strongly typed artifact-store identity used by Mongo and binary stores."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
from keble_helpers.typings import PydanticModelConfig
|
|
8
|
+
from pydantic import BaseModel, Field, model_validator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ArtifactStoreBackend(StrEnum):
|
|
12
|
+
"""Enumerate storage backends whose object keys are not interchangeable.
|
|
13
|
+
|
|
14
|
+
Steps:
|
|
15
|
+
1. distinguish durable OSS, shared local directory, and test memory stores.
|
|
16
|
+
|
|
17
|
+
Side effects if changes:
|
|
18
|
+
- Mongo namespace markers persist these values as compatibility evidence;
|
|
19
|
+
- every new backend must implement the artifact-store owner-claim protocol.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
OSS = "oss"
|
|
23
|
+
LOCAL_DIRECTORY = "local_directory"
|
|
24
|
+
MEMORY = "memory"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ArtifactStoreIdentity(BaseModel):
|
|
28
|
+
"""Describe every non-secret location component that determines stored bytes.
|
|
29
|
+
|
|
30
|
+
Steps:
|
|
31
|
+
1. retain backend kind plus its canonical object-key prefix;
|
|
32
|
+
2. require endpoint/bucket only for OSS and directory only for local storage;
|
|
33
|
+
3. forbid irrelevant fields so backend swaps cannot compare equal.
|
|
34
|
+
|
|
35
|
+
Side effects if changes:
|
|
36
|
+
- scraper startup compares this exact value with the Mongo namespace marker;
|
|
37
|
+
- changing OSS endpoint/bucket or local directory requires explicit migration;
|
|
38
|
+
- access credentials and download base URLs remain safely excluded.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
model_config = PydanticModelConfig.default(frozen=True, extra="forbid")
|
|
42
|
+
|
|
43
|
+
backend: ArtifactStoreBackend
|
|
44
|
+
prefix: str = Field(min_length=1, max_length=512)
|
|
45
|
+
endpoint: str | None = Field(default=None, min_length=1, max_length=2_048)
|
|
46
|
+
bucket: str | None = Field(default=None, min_length=1, max_length=255)
|
|
47
|
+
directory: str | None = Field(default=None, min_length=1, max_length=4_096)
|
|
48
|
+
|
|
49
|
+
@model_validator(mode="after")
|
|
50
|
+
def validate_backend_fields(self) -> "ArtifactStoreIdentity":
|
|
51
|
+
"""Require the exact location fields owned by the selected backend.
|
|
52
|
+
|
|
53
|
+
Steps:
|
|
54
|
+
1. validate endpoint/bucket for OSS;
|
|
55
|
+
2. validate directory for local storage;
|
|
56
|
+
3. require memory identity to contain only its fixed prefix.
|
|
57
|
+
|
|
58
|
+
Side effects if changes:
|
|
59
|
+
- malformed markers must fail before artifact reads or cleanup;
|
|
60
|
+
- identity equality remains a total backend/location comparison.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
if self.backend is ArtifactStoreBackend.OSS:
|
|
64
|
+
if self.endpoint is None or self.bucket is None or self.directory is not None:
|
|
65
|
+
raise ValueError("OSS identity requires endpoint/bucket and forbids directory")
|
|
66
|
+
elif self.backend is ArtifactStoreBackend.LOCAL_DIRECTORY:
|
|
67
|
+
if self.directory is None or self.endpoint is not None or self.bucket is not None:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
"local artifact identity requires directory and forbids OSS fields"
|
|
70
|
+
)
|
|
71
|
+
elif any(value is not None for value in (self.endpoint, self.bucket, self.directory)):
|
|
72
|
+
raise ValueError("memory artifact identity forbids external location fields")
|
|
73
|
+
return self
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
__all__ = ["ArtifactStoreBackend", "ArtifactStoreIdentity"]
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Explicit process composition for API and worker runtimes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from celery import Celery
|
|
10
|
+
from pydantic import SecretStr
|
|
11
|
+
from redis.asyncio import Redis
|
|
12
|
+
|
|
13
|
+
from keble_scraper_api.application.admin import (
|
|
14
|
+
ArtifactAdminService,
|
|
15
|
+
MaintenanceAdminService,
|
|
16
|
+
ProxyAdminService,
|
|
17
|
+
)
|
|
18
|
+
from keble_scraper_api.application.artifacts import ArtifactService
|
|
19
|
+
from keble_scraper_api.application.auth import (
|
|
20
|
+
OperatorAuthService,
|
|
21
|
+
SessionCipher,
|
|
22
|
+
StaticServiceKeyAuthorizer,
|
|
23
|
+
)
|
|
24
|
+
from keble_scraper_api.application.jobs import (
|
|
25
|
+
AuditService,
|
|
26
|
+
MaintenanceService,
|
|
27
|
+
ScrapeExecutionService,
|
|
28
|
+
ScrapeJobService,
|
|
29
|
+
)
|
|
30
|
+
from keble_scraper_api.application.proxy_health import ProxyHealthService
|
|
31
|
+
from keble_scraper_api.infrastructure.artifact_store import (
|
|
32
|
+
InMemoryArtifactStore,
|
|
33
|
+
LocalDirectoryArtifactStore,
|
|
34
|
+
OssArtifactStore,
|
|
35
|
+
)
|
|
36
|
+
from keble_scraper_api.infrastructure.callbacks import (
|
|
37
|
+
CallbackOutboxService,
|
|
38
|
+
RegisteredHttpCompletionCallback,
|
|
39
|
+
WebhookSigner,
|
|
40
|
+
)
|
|
41
|
+
from keble_scraper_api.infrastructure.celery_factory import build_celery
|
|
42
|
+
from keble_scraper_api.infrastructure.cloudflare import CloudflareBrowserFetcher
|
|
43
|
+
from keble_scraper_api.infrastructure.credentials import ProxyCredentialCipher
|
|
44
|
+
from keble_scraper_api.infrastructure.dispatcher import CeleryJobDispatcher
|
|
45
|
+
from keble_scraper_api.infrastructure.http_fetcher import SecureHttpFetcher
|
|
46
|
+
from keble_scraper_api.infrastructure.mongo import MongoScraperRepository
|
|
47
|
+
from keble_scraper_api.infrastructure.proxy_router import RedisProxyRouter
|
|
48
|
+
from keble_scraper_api.infrastructure.request_profiles import RequestProfileRegistry
|
|
49
|
+
from keble_scraper_api.infrastructure.url_policy import (
|
|
50
|
+
DnsOverHttpsHostResolver,
|
|
51
|
+
PublicUrlPolicy,
|
|
52
|
+
)
|
|
53
|
+
from keble_scraper_api.protocols import ArtifactStoreProtocol
|
|
54
|
+
from keble_scraper_api.settings import RuntimeEnvironment, ScraperSettings
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class ScraperContainer:
|
|
59
|
+
"""Fully assembled process service graph with explicit resource ownership.
|
|
60
|
+
|
|
61
|
+
Steps:
|
|
62
|
+
1. construct one process graph from a singular settings object;
|
|
63
|
+
2. start indexes, namespace fences, and readiness before use;
|
|
64
|
+
3. close every pooled external resource in deterministic order.
|
|
65
|
+
|
|
66
|
+
Side effects if changes:
|
|
67
|
+
- API/worker startup order and every external infrastructure client.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
settings: ScraperSettings
|
|
71
|
+
repository: MongoScraperRepository
|
|
72
|
+
redis: Redis
|
|
73
|
+
celery: Celery
|
|
74
|
+
artifact_store: ArtifactStoreProtocol
|
|
75
|
+
http_fetcher: SecureHttpFetcher
|
|
76
|
+
browser_fetcher: CloudflareBrowserFetcher
|
|
77
|
+
callback_transport: RegisteredHttpCompletionCallback
|
|
78
|
+
auth: OperatorAuthService
|
|
79
|
+
service_keys: StaticServiceKeyAuthorizer
|
|
80
|
+
jobs: ScrapeJobService
|
|
81
|
+
execution: ScrapeExecutionService
|
|
82
|
+
maintenance: MaintenanceService
|
|
83
|
+
proxy_health: ProxyHealthService
|
|
84
|
+
callbacks: CallbackOutboxService
|
|
85
|
+
proxy_admin: ProxyAdminService
|
|
86
|
+
artifact_admin: ArtifactAdminService
|
|
87
|
+
maintenance_admin: MaintenanceAdminService
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def build(cls, *, settings: ScraperSettings | None = None) -> "ScraperContainer":
|
|
91
|
+
"""Construct the complete graph once from one typed settings value.
|
|
92
|
+
|
|
93
|
+
Steps:
|
|
94
|
+
1. create shared Mongo/Redis/Rabbit clients and encrypted credential owner;
|
|
95
|
+
2. wire protocols into application services with no service locator;
|
|
96
|
+
3. select private OSS in configured environments and isolated memory only
|
|
97
|
+
for local/test operation without OSS credentials.
|
|
98
|
+
|
|
99
|
+
Side effects if changes:
|
|
100
|
+
- connections, vendor clients, queue publication, and auth behavior.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
resolved = settings or ScraperSettings()
|
|
104
|
+
proxy_key = _stable_fernet_key(
|
|
105
|
+
configured=resolved.proxy_secret_fernet_key,
|
|
106
|
+
fallback=resolved.service_key_current,
|
|
107
|
+
)
|
|
108
|
+
proxy_cipher = ProxyCredentialCipher(fernet_key=proxy_key)
|
|
109
|
+
repository = MongoScraperRepository.build(
|
|
110
|
+
settings=resolved,
|
|
111
|
+
proxy_cipher=proxy_cipher,
|
|
112
|
+
)
|
|
113
|
+
redis = Redis.from_url(
|
|
114
|
+
resolved.redis_uri.get_secret_value(),
|
|
115
|
+
decode_responses=True,
|
|
116
|
+
)
|
|
117
|
+
celery = build_celery(settings=resolved)
|
|
118
|
+
dispatcher = CeleryJobDispatcher(celery=celery, settings=resolved)
|
|
119
|
+
profiles = RequestProfileRegistry()
|
|
120
|
+
url_policy = PublicUrlPolicy(
|
|
121
|
+
resolver=(
|
|
122
|
+
DnsOverHttpsHostResolver(
|
|
123
|
+
endpoint=resolved.development_dns_over_https_url,
|
|
124
|
+
proxy_url=resolved.development_http_proxy_url,
|
|
125
|
+
)
|
|
126
|
+
if resolved.development_dns_over_https_url is not None
|
|
127
|
+
and resolved.development_http_proxy_url is not None
|
|
128
|
+
else None
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
http_fetcher = SecureHttpFetcher(settings=resolved, url_policy=url_policy)
|
|
132
|
+
browser_fetcher = CloudflareBrowserFetcher(
|
|
133
|
+
settings=resolved,
|
|
134
|
+
url_policy=url_policy,
|
|
135
|
+
)
|
|
136
|
+
proxy_router = RedisProxyRouter(
|
|
137
|
+
redis=redis,
|
|
138
|
+
settings=resolved,
|
|
139
|
+
proxy_repository=repository,
|
|
140
|
+
)
|
|
141
|
+
artifact_store = build_artifact_store(settings=resolved)
|
|
142
|
+
artifacts = ArtifactService(repository=repository, store=artifact_store)
|
|
143
|
+
audit = AuditService(repository=repository)
|
|
144
|
+
jobs = ScrapeJobService(
|
|
145
|
+
repository=repository,
|
|
146
|
+
dispatcher=dispatcher,
|
|
147
|
+
profiles=profiles,
|
|
148
|
+
audit=audit,
|
|
149
|
+
)
|
|
150
|
+
execution = ScrapeExecutionService(
|
|
151
|
+
settings=resolved,
|
|
152
|
+
jobs=repository,
|
|
153
|
+
cache=repository,
|
|
154
|
+
artifacts=artifacts,
|
|
155
|
+
router=proxy_router,
|
|
156
|
+
profiles=profiles,
|
|
157
|
+
http_fetcher=http_fetcher,
|
|
158
|
+
browser_fetcher=browser_fetcher,
|
|
159
|
+
)
|
|
160
|
+
maintenance = MaintenanceService(
|
|
161
|
+
jobs=repository,
|
|
162
|
+
outbox=repository,
|
|
163
|
+
cache=repository,
|
|
164
|
+
artifacts=artifacts,
|
|
165
|
+
history=repository,
|
|
166
|
+
dispatcher=dispatcher,
|
|
167
|
+
dispatch_concurrency=resolved.maintenance_dispatch_concurrency,
|
|
168
|
+
history_retention_batch_size=resolved.history_retention_batch_size,
|
|
169
|
+
)
|
|
170
|
+
signer = WebhookSigner(key=resolved.callback_hmac_current)
|
|
171
|
+
callback = RegisteredHttpCompletionCallback(settings=resolved, signer=signer)
|
|
172
|
+
callbacks = CallbackOutboxService(
|
|
173
|
+
repository=repository,
|
|
174
|
+
callback=callback,
|
|
175
|
+
lease_seconds=resolved.worker_lease_seconds,
|
|
176
|
+
retry_base_seconds=resolved.retry_base_seconds,
|
|
177
|
+
)
|
|
178
|
+
proxy_health = ProxyHealthService(
|
|
179
|
+
repository=repository,
|
|
180
|
+
fetcher=http_fetcher,
|
|
181
|
+
settings=resolved,
|
|
182
|
+
)
|
|
183
|
+
session_key = (
|
|
184
|
+
resolved.session_fernet_key
|
|
185
|
+
if resolved.session_fernet_key is not None
|
|
186
|
+
else _stable_fernet_key(
|
|
187
|
+
configured=None,
|
|
188
|
+
fallback=resolved.callback_hmac_current,
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
auth = OperatorAuthService(
|
|
192
|
+
settings=resolved,
|
|
193
|
+
cipher=SessionCipher(key=session_key),
|
|
194
|
+
)
|
|
195
|
+
service_keys = StaticServiceKeyAuthorizer(
|
|
196
|
+
current=resolved.service_key_current,
|
|
197
|
+
next_key=resolved.service_key_next,
|
|
198
|
+
)
|
|
199
|
+
proxy_admin = ProxyAdminService(
|
|
200
|
+
repository=repository,
|
|
201
|
+
router=proxy_router,
|
|
202
|
+
audit=audit,
|
|
203
|
+
settings=resolved,
|
|
204
|
+
)
|
|
205
|
+
artifact_admin = ArtifactAdminService(
|
|
206
|
+
artifacts=artifacts,
|
|
207
|
+
audit=audit,
|
|
208
|
+
settings=resolved,
|
|
209
|
+
)
|
|
210
|
+
maintenance_admin = MaintenanceAdminService(
|
|
211
|
+
maintenance=maintenance,
|
|
212
|
+
audit=audit,
|
|
213
|
+
)
|
|
214
|
+
return cls(
|
|
215
|
+
settings=resolved,
|
|
216
|
+
repository=repository,
|
|
217
|
+
redis=redis,
|
|
218
|
+
celery=celery,
|
|
219
|
+
artifact_store=artifact_store,
|
|
220
|
+
http_fetcher=http_fetcher,
|
|
221
|
+
browser_fetcher=browser_fetcher,
|
|
222
|
+
callback_transport=callback,
|
|
223
|
+
auth=auth,
|
|
224
|
+
service_keys=service_keys,
|
|
225
|
+
jobs=jobs,
|
|
226
|
+
execution=execution,
|
|
227
|
+
maintenance=maintenance,
|
|
228
|
+
proxy_health=proxy_health,
|
|
229
|
+
callbacks=callbacks,
|
|
230
|
+
proxy_admin=proxy_admin,
|
|
231
|
+
artifact_admin=artifact_admin,
|
|
232
|
+
maintenance_admin=maintenance_admin,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
async def start(self) -> None:
|
|
236
|
+
"""Create indexes and verify namespace/shared resources before work.
|
|
237
|
+
|
|
238
|
+
Steps:
|
|
239
|
+
1. create required Mongo indexes;
|
|
240
|
+
2. claim the binary-store namespace for one stable deployment owner;
|
|
241
|
+
3. bind Mongo artifact rows to the complete binary-store identity;
|
|
242
|
+
4. verify Mongo and Redis readiness only after both fences pass.
|
|
243
|
+
|
|
244
|
+
Side effects if changes:
|
|
245
|
+
- Mongo index/namespace state and process readiness.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
await self.repository.ensure_indexes()
|
|
249
|
+
store_identity = self.artifact_store.namespace_identity()
|
|
250
|
+
namespace_owner = self.settings.resolved_artifact_namespace_owner
|
|
251
|
+
await self.artifact_store.ensure_namespace_owner(owner=namespace_owner)
|
|
252
|
+
await self.repository.ensure_artifact_store_namespace(
|
|
253
|
+
probe_object_key=self.artifact_store.object_key(content_sha256="0" * 64),
|
|
254
|
+
store_identity=store_identity,
|
|
255
|
+
namespace_owner=namespace_owner,
|
|
256
|
+
)
|
|
257
|
+
await self.repository.readiness()
|
|
258
|
+
await self.redis.ping()
|
|
259
|
+
|
|
260
|
+
async def close(self) -> None:
|
|
261
|
+
"""Close pooled HTTP, Redis, then Mongo process-owned resources.
|
|
262
|
+
|
|
263
|
+
Steps:
|
|
264
|
+
1. close direct/proxy, Cloudflare, and callback pooled HTTP transports;
|
|
265
|
+
2. close the shared Redis connection pool;
|
|
266
|
+
3. close the Mongo client after no service can issue more work.
|
|
267
|
+
|
|
268
|
+
Side effects if changes:
|
|
269
|
+
- graceful API/worker shutdown and connection leakage.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
await self.http_fetcher.close()
|
|
273
|
+
await self.browser_fetcher.close()
|
|
274
|
+
await self.callback_transport.close()
|
|
275
|
+
await self.redis.aclose()
|
|
276
|
+
await self.repository.close()
|
|
277
|
+
|
|
278
|
+
async def readiness(self) -> dict[str, str]:
|
|
279
|
+
"""Verify Mongo/Redis without privileged vendor or service calls.
|
|
280
|
+
|
|
281
|
+
Side effects if changes:
|
|
282
|
+
- deployment health gates and rollback decisions.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
await self.repository.readiness()
|
|
286
|
+
await self.redis.ping()
|
|
287
|
+
return {
|
|
288
|
+
"status": "ready",
|
|
289
|
+
"mongo": "ready",
|
|
290
|
+
"redis": "ready",
|
|
291
|
+
"artifactStore": (
|
|
292
|
+
"private-oss"
|
|
293
|
+
if self.settings.oss_bucket is not None
|
|
294
|
+
else "shared-local-directory"
|
|
295
|
+
if self.settings.local_artifact_directory is not None
|
|
296
|
+
else "ephemeral-development"
|
|
297
|
+
),
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _stable_fernet_key(
|
|
302
|
+
*, configured: SecretStr | None, fallback: SecretStr
|
|
303
|
+
) -> SecretStr:
|
|
304
|
+
"""Derive a deterministic URL-safe key only for non-production fallback use.
|
|
305
|
+
|
|
306
|
+
Side effects if changes:
|
|
307
|
+
- development proxy/session ciphertext compatibility across processes.
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
if configured is not None:
|
|
311
|
+
return configured
|
|
312
|
+
digest = hashlib.sha256(fallback.get_secret_value().encode()).digest()
|
|
313
|
+
return SecretStr(base64.urlsafe_b64encode(digest).decode())
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def build_artifact_store(*, settings: ScraperSettings) -> ArtifactStoreProtocol:
|
|
317
|
+
"""Select private OSS or an explicit non-production ephemeral test store.
|
|
318
|
+
|
|
319
|
+
Steps:
|
|
320
|
+
1. prefer the complete private OSS tuple when configured;
|
|
321
|
+
2. otherwise select the explicit local-directory pair in local/test;
|
|
322
|
+
3. use process-memory storage only for credential-free local/test operation.
|
|
323
|
+
|
|
324
|
+
Side effects if changes:
|
|
325
|
+
- API, workers, and guarded maintenance commands share this sole selector;
|
|
326
|
+
- artifact durability and raw-download availability depend on the result.
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
configured = (
|
|
330
|
+
settings.oss_endpoint,
|
|
331
|
+
settings.oss_bucket,
|
|
332
|
+
settings.oss_access_key_id,
|
|
333
|
+
settings.oss_access_key_secret,
|
|
334
|
+
)
|
|
335
|
+
if all(value is not None for value in configured):
|
|
336
|
+
return OssArtifactStore(
|
|
337
|
+
endpoint=str(settings.oss_endpoint or ""),
|
|
338
|
+
bucket_name=settings.oss_bucket or "",
|
|
339
|
+
access_key_id=settings.oss_access_key_id or "",
|
|
340
|
+
access_key_secret=(
|
|
341
|
+
settings.oss_access_key_secret.get_secret_value()
|
|
342
|
+
if settings.oss_access_key_secret is not None
|
|
343
|
+
else ""
|
|
344
|
+
),
|
|
345
|
+
prefix=settings.oss_prefix,
|
|
346
|
+
)
|
|
347
|
+
if (
|
|
348
|
+
settings.local_artifact_directory is not None
|
|
349
|
+
and settings.local_artifact_base_url is not None
|
|
350
|
+
):
|
|
351
|
+
return LocalDirectoryArtifactStore(
|
|
352
|
+
directory=settings.local_artifact_directory,
|
|
353
|
+
base_url=str(settings.local_artifact_base_url),
|
|
354
|
+
prefix=settings.oss_prefix,
|
|
355
|
+
)
|
|
356
|
+
if settings.environment in {
|
|
357
|
+
RuntimeEnvironment.DEVELOPMENT,
|
|
358
|
+
RuntimeEnvironment.TEST,
|
|
359
|
+
}:
|
|
360
|
+
return InMemoryArtifactStore()
|
|
361
|
+
raise ValueError("private OSS configuration is required outside development/test")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Typed sanitized scraper application failures."""
|
|
2
|
+
|
|
3
|
+
from keble_scraper_contract import ScrapeLocalFailureCode
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
ScraperErrorCode = ScrapeLocalFailureCode
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ScraperDomainError(Exception):
|
|
10
|
+
"""Sanitized typed failure crossing application and HTTP boundaries.
|
|
11
|
+
|
|
12
|
+
Side effects if changes:
|
|
13
|
+
- worker outcome conversion and FastAPI exception responses.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, *, code: ScraperErrorCode, message: str) -> None:
|
|
17
|
+
"""Initialize one safe code/message pair without transport details.
|
|
18
|
+
|
|
19
|
+
Side effects if changes:
|
|
20
|
+
- every raised scraper domain failure serializes through this value.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.code = code
|
|
25
|
+
self.message = message
|